From d62ea95dcec8f2f2e71621ffe5e3df3c0bb86b6f 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. Amounts already go through `bitcoin::amount::serde`, which serializes both ways. The remaining `custom_serde` deserializers gain a matching serializer that writes the Electrum wire representation back out rather than the Rust type's own serde: - `to_consensus_hex` - `headers_to_hex_list` (writes the v1.6 array-of-hex form) - `feerate_to_btc_per_kb` / `feerate_opt_to_btc_per_kb` (writes `-1.0` for `None`) - `feerate_to_sat_per_byte` - `weight_to_vb` - `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 | 77 ++++++++++- src/response.rs | 312 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 348 insertions(+), 41 deletions(-) diff --git a/src/custom_serde.rs b/src/custom_serde.rs index 25e3add..161e05c 100644 --- a/src/custom_serde.rs +++ b/src/custom_serde.rs @@ -1,10 +1,10 @@ use bitcoin::{ - consensus::{deserialize_partial, encode::deserialize_hex}, + consensus::{deserialize_partial, encode::deserialize_hex, Encodable}, hex::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) +} + /// Deserializes headers from either: /// - A single concatenated hex string (pre-1.6: `"hex"` field) /// - An array of individual hex strings (v1.6+: `"headers"` field) @@ -59,6 +67,15 @@ where } } +/// Serializes headers in the v1.6 format: an array of individual hex strings. +pub fn headers_to_hex_list(values: &[T], serializer: S) -> Result +where + T: Encodable, + S: Serializer, +{ + serializer.collect_seq(values.iter().map(bitcoin::consensus::encode::serialize_hex)) +} + 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")); @@ -75,6 +92,17 @@ where feerate_from_btc_per_kb_f32(f32::deserialize(deserializer)?) } +/// [`bitcoin::FeeRate`] → BTC/kvB. +pub fn feerate_to_btc_per_kb( + fee_rate: &bitcoin::FeeRate, + serializer: S, +) -> Result +where + S: Serializer, +{ + (fee_rate.to_sat_per_kwu() as f32 / (100_000_000.0 / 4.0)).serialize(serializer) +} + /// BTC/kvB → [`bitcoin::FeeRate`]; negative → `None`. pub fn feerate_opt_from_btc_per_kb<'de, D>( deserializer: D, @@ -89,6 +117,21 @@ where feerate_from_btc_per_kb_f32(btc_per_kvb).map(Some) } +/// 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, +{ + match fee_rate { + Some(fee_rate) => feerate_to_btc_per_kb(fee_rate, serializer), + None => (-1.0_f32).serialize(serializer), + } +} + pub fn feerate_from_sat_per_byte<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -98,6 +141,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>, @@ -109,6 +163,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 all_inputs_confirmed_bool_from_height<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -123,6 +184,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 5e84ed7..8803807 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; @@ -19,8 +23,8 @@ use crate::DoubleSHA; /// Returns the server's software version and the negotiated protocol version. /// /// See: -#[derive(Debug, Clone, serde::Deserialize)] -#[serde(from = "(String, String)")] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(from = "(String, String)", into = "(String, String)")] pub struct ServerVersionResp { /// Server software version (e.g. `"ElectrumX 1.18.0"`). pub server_software: String, @@ -29,6 +33,12 @@ pub struct ServerVersionResp { pub protocol_version: String, } +impl From for (String, String) { + fn from(resp: ServerVersionResp) -> Self { + (resp.server_software, resp.protocol_version) + } +} + impl From<(String, String)> for ServerVersionResp { fn from((server_software, protocol_version): (String, String)) -> Self { Self { @@ -39,22 +49,28 @@ impl From<(String, String)> for ServerVersionResp { } /// 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. @@ -65,7 +81,7 @@ pub struct HeaderWithProofResp { /// /// 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)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct HeadersResp { /// The number of headers returned. pub count: usize, @@ -74,7 +90,8 @@ pub struct HeadersResp { #[serde( alias = "hex", alias = "headers", - deserialize_with = "crate::custom_serde::headers_from_hex_or_list" + deserialize_with = "crate::custom_serde::headers_from_hex_or_list", + serialize_with = "crate::custom_serde::headers_to_hex_list" )] pub headers: Vec, @@ -86,7 +103,7 @@ pub struct HeadersResp { /// /// 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)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct HeadersWithCheckpointResp { /// The number of headers returned. pub count: usize, @@ -95,7 +112,8 @@ pub struct HeadersWithCheckpointResp { #[serde( alias = "hex", alias = "headers", - deserialize_with = "crate::custom_serde::headers_from_hex_or_list" + deserialize_with = "crate::custom_serde::headers_from_hex_or_list", + serialize_with = "crate::custom_serde::headers_to_hex_list" )] pub headers: Vec, @@ -110,21 +128,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, @@ -133,7 +155,7 @@ pub struct HeadersSubscribeResp { } /// Response to the `"blockchain.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. @@ -142,7 +164,7 @@ pub struct RelayFeeResp { } /// 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(with = "bitcoin::amount::serde::as_sat")] @@ -155,7 +177,7 @@ pub struct GetBalanceResp { pub unconfirmed: SignedAmount, } -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(untagged)] pub enum Tx { Mempool(MempoolTx), @@ -193,7 +215,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")] @@ -204,7 +226,7 @@ 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")] @@ -217,13 +239,14 @@ pub struct MempoolTx { /// 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, @@ -243,18 +266,21 @@ pub struct Utxo { /// 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, @@ -294,7 +320,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. @@ -304,21 +330,27 @@ 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 `"blockchain.transaction.broadcast_package"` method (non-verbose mode). /// /// See: -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct BroadcastPackageResp { /// Whether the package was accepted by the server. pub success: bool, @@ -330,7 +362,7 @@ pub struct BroadcastPackageResp { } /// A per-transaction rejection inside [`BroadcastPackageResp::errors`]. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct BroadcastPackageError { /// The rejected transaction's txid. pub txid: bitcoin::Txid, @@ -344,23 +376,32 @@ pub struct BroadcastPackageError { /// Provides fee-related information about the server's mempool. /// /// See: -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] 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")] + #[serde( + deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb", + serialize_with = "crate::custom_serde::feerate_to_btc_per_kb" + )] pub mempoolminfee: bitcoin::FeeRate, /// The minimum relay fee rate. - #[serde(deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb")] + #[serde( + deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb", + serialize_with = "crate::custom_serde::feerate_to_btc_per_kb" + )] pub minrelaytxfee: bitcoin::FeeRate, /// The incremental relay fee rate. - #[serde(deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb")] + #[serde( + deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb", + serialize_with = "crate::custom_serde::feerate_to_btc_per_kb" + )] pub incrementalrelayfee: bitcoin::FeeRate, } /// 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, @@ -389,7 +430,7 @@ 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, @@ -408,4 +449,197 @@ mod tests { assert_eq!(response.unconfirmed, SignedAmount::from_sat(-100)); } + + /// 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: SignedAmount::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() }); + assert_round_trip(ServerVersionResp { + server_software: "ElectrumX 1.18.0".to_string(), + protocol_version: "1.6".to_string(), + }); + assert_round_trip(BroadcastPackageResp { + success: false, + errors: Some(vec![BroadcastPackageError { + txid: txid(), + error: "bad-txns-inputs-missingorspent".to_string(), + }]), + }); + assert_round_trip(MempoolInfoResp { + mempoolminfee: bitcoin::FeeRate::from_sat_per_kwu(25_000), + minrelaytxfee: bitcoin::FeeRate::from_sat_per_kwu(25_000), + incrementalrelayfee: bitcoin::FeeRate::from_sat_per_kwu(25_000), + }); + // 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), + ); + } }