From f5bb8d20ce29763c65253a1a1f211bc1f9fdff23 Mon Sep 17 00:00:00 2001 From: JHB <16675200+jharveyb@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:43:21 -0400 Subject: [PATCH 1/2] fix: id_from_pos merkle compatibility with electrs <0.11.1 --- src/api.rs | 6 +++--- src/client.rs | 2 +- src/raw_client.rs | 2 +- src/types.rs | 26 +++++++++++++++++++++++++- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index 2a31b4f..da64f12 100644 --- a/src/api.rs +++ b/src/api.rs @@ -174,7 +174,7 @@ where &self, height: usize, tx_pos: usize, - ) -> Result { + ) -> Result { (**self).txid_from_pos_with_merkle(height, tx_pos) } @@ -444,7 +444,7 @@ pub trait ElectrumApi { &self, height: usize, tx_pos: usize, - ) -> Result; + ) -> Result; /// Returns the capabilities of the server. fn server_features(&self) -> Result; @@ -689,7 +689,7 @@ mod test { &self, _: usize, _: usize, - ) -> Result { + ) -> Result { unreachable!() } diff --git a/src/client.rs b/src/client.rs index d86880f..473cfdb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -406,7 +406,7 @@ impl ElectrumApi for Client { &self, height: usize, tx_pos: usize, - ) -> Result { + ) -> Result { impl_inner_call!(self, txid_from_pos_with_merkle, height, tx_pos) } diff --git a/src/raw_client.rs b/src/raw_client.rs index 3754851..8f6b51f 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -1382,7 +1382,7 @@ impl ElectrumApi for RawClient { &self, height: usize, tx_pos: usize, - ) -> Result { + ) -> Result { let params = vec![ Param::Usize(height), Param::Usize(tx_pos), diff --git a/src/types.rs b/src/types.rs index b6a2c9a..f369c53 100644 --- a/src/types.rs +++ b/src/types.rs @@ -347,8 +347,11 @@ pub struct GetMerkleRes { /// Response to a [`txid_from_pos_with_merkle`](../client/struct.Client.html#method.txid_from_pos_with_merkle) /// request. #[derive(Clone, Debug, Deserialize)] -pub struct TxidFromPosRes { +pub struct TxidFromPosMerkleRes { /// Txid of the transaction. + /// + /// `electrs` before v0.11.1 named this field `tx_id`, which is accepted as an alias. + #[serde(alias = "tx_id")] pub tx_hash: Txid, /// The merkle path of the transaction. #[serde(deserialize_with = "from_hex_array")] @@ -556,6 +559,27 @@ mod tests { assert_eq!(script_status, script_status_back); } + #[test] + fn txid_from_pos_with_merkle_accepts_legacy_electrs_key() { + use super::TxidFromPosMerkleRes; + use bitcoin::Txid; + use std::str::FromStr; + + let expected = + Txid::from_str("1f7ff3c407f33eabc8bec7d2cc230948f2249ec8e591bcf6f971ca9366c8788d") + .unwrap(); + + // The two JSON keys used by electrs versions <0.11.1, and version 0.11.1. + for key in ["tx_id", "tx_hash"] { + let json = format!( + r#"{{"{key}":"1f7ff3c407f33eabc8bec7d2cc230948f2249ec8e591bcf6f971ca9366c8788d","merkle":["a9642263a86519a8b85a4d3297f58265c1e588803f6ef113f23bb889f5f9bc6e"]}}"# + ); + let parsed: TxidFromPosMerkleRes = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.tx_hash, expected, "failed for key {key}"); + assert_eq!(parsed.merkle.len(), 1); + } + } + #[test] fn test_request_serialization_without_authorization() { let req = Request::new_id(1, "server.version", vec![]); From 04b737a5d21536ef832294092912bafcc03495dc Mon Sep 17 00:00:00 2001 From: JHB <16675200+jharveyb@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:56:04 -0400 Subject: [PATCH 2/2] fix: id_from_pos compatibility with electrs <=0.11.1 --- src/raw_client.rs | 10 ++++++++-- src/types.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/raw_client.rs b/src/raw_client.rs index 8f6b51f..6f6abe6 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -1367,7 +1367,13 @@ impl ElectrumApi for RawClient { } fn txid_from_pos(&self, height: usize, tx_pos: usize) -> Result { - let params = vec![Param::Usize(height), Param::Usize(tx_pos)]; + // The `merkle` flag is optional per the protocol, but some server implementations (e.g. + // `electrs`) reject requests that omit it, so send it explicitly. + let params = vec![ + Param::Usize(height), + Param::Usize(tx_pos), + Param::Bool(false), + ]; let req = Request::new_id( self.last_id.fetch_add(1, Ordering::SeqCst), "blockchain.transaction.id_from_pos", @@ -1375,7 +1381,7 @@ impl ElectrumApi for RawClient { ); let result = self.call(req)?; - Ok(serde_json::from_value(result)?) + Ok(serde_json::from_value::(result)?.into()) } fn txid_from_pos_with_merkle( diff --git a/src/types.rs b/src/types.rs index f369c53..d454591 100644 --- a/src/types.rs +++ b/src/types.rs @@ -344,6 +344,31 @@ pub struct GetMerkleRes { pub merkle: Vec<[u8; 32]>, } +/// Response to a [`txid_from_pos`](../client/struct.Client.html#method.txid_from_pos) request. +/// +/// The protocol specifies a bare transaction hash string when `merkle` is `false`, but some +/// server implementations (e.g. `electrs`) reply with a JSON object holding a `tx_hash` field +/// instead, so both shapes are accepted. `electrs` before v0.11.1 named that field `tx_id`. +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum TxidFromPosRes { + /// A bare transaction hash. + Hash(Txid), + /// An object holding the transaction hash. + Object { + #[serde(alias = "tx_id")] + tx_hash: Txid, + }, +} + +impl From for Txid { + fn from(raw: TxidFromPosRes) -> Self { + match raw { + TxidFromPosRes::Hash(tx_hash) | TxidFromPosRes::Object { tx_hash } => tx_hash, + } + } +} + /// Response to a [`txid_from_pos_with_merkle`](../client/struct.Client.html#method.txid_from_pos_with_merkle) /// request. #[derive(Clone, Debug, Deserialize)] @@ -559,6 +584,31 @@ mod tests { assert_eq!(script_status, script_status_back); } + #[test] + fn txid_from_pos_accepts_both_response_shapes() { + use super::TxidFromPosRes; + use bitcoin::Txid; + use std::str::FromStr; + + let expected = + Txid::from_str("1f7ff3c407f33eabc8bec7d2cc230948f2249ec8e591bcf6f971ca9366c8788d") + .unwrap(); + + // The bare hash string specified by the protocol. + let spec_shape = r#""1f7ff3c407f33eabc8bec7d2cc230948f2249ec8e591bcf6f971ca9366c8788d""#; + let parsed: TxidFromPosRes = serde_json::from_str(spec_shape).unwrap(); + assert_eq!(Txid::from(parsed), expected); + + // The two JSON keys used by electrs versions <0.11.1, and version 0.11.1. + for key in ["tx_id", "tx_hash"] { + let json = format!( + r#"{{"{key}":"1f7ff3c407f33eabc8bec7d2cc230948f2249ec8e591bcf6f971ca9366c8788d","merkle":["a9642263a86519a8b85a4d3297f58265c1e588803f6ef113f23bb889f5f9bc6e"]}}"# + ); + let parsed: TxidFromPosRes = serde_json::from_str(&json).unwrap(); + assert_eq!(Txid::from(parsed), expected, "failed for key {key}"); + } + } + #[test] fn txid_from_pos_with_merkle_accepts_legacy_electrs_key() { use super::TxidFromPosMerkleRes;