Implement serde functions in as_consensus
What changed, and why it matters
This commit adds a new helper module that lets Rust Bitcoin types be serialized and deserialized using Bitcoin's standard binary encoding, exposed through the popular serde serialization framework. It is a feature/refactoring change: it introduces a cleaner way to get the same behavior that previously required more verbose code. There is no indication in the commit that it fixes a security bug or vulnerability.
No security action required. Treat as a normal feature/refactoring commit. If auditing, verify that the new serde wrapper correctly propagates deserialization errors and does not introduce unexpected panic paths, though the diff shows errors are mapped to serde::de::Error::custom.
Security signals we found
No security-relevant signals present in the diff or commit message.
Change is a new serde convenience wrapper around existing consensus encode/decode code.
No bounds checks, memory safety fixes, input validation changes, or cryptographic corrections are visible.
Evidence from the diff
The change introduces primitives::serde_as_consensus (re-exported as bitcoin::serde_as_consensus) providing serialize/deserialize functions plus opt and vec submodules. These functions delegate to the existing consensus Encodable/Decodable traits, using hex for human-readable serializers and raw bytes for non-human-readable ones. The bitcoin example is updated to use the new module, and tests are added for JSON and bincode round-trips of Header and Block. The commit does not alter consensus-encoding logic itself, nor does it patch any reported vulnerability.
Changed components
primitives/src/serde_as_consensus.rs (new)primitives/src/lib.rsbitcoin/src/lib.rsbitcoin/examples/serde.rsprimitives/src/block.rs (tests only)Inspect captured patch +320 / −13
diff --git a/bitcoin/examples/serde.rs b/bitcoin/examples/serde.rs
index a40f65f4..74080cf4 100644
--- a/bitcoin/examples/serde.rs
+++ b/bitcoin/examples/serde.rs
@@ -4,8 +4,6 @@
//! For integer types that can have multiple units we typically provide a few different modules.
use bitcoin::block::{Header, Version};
-use bitcoin::consensus::serde::Hex;
-use bitcoin::consensus::{self};
use bitcoin::{
amount, fee_rate, Amount, BlockHash, BlockTime, CompactTarget, FeeRate, TxMerkleNode,
};
@@ -13,30 +11,26 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct Foo {
- /// Consensus encoded into hex is often the best option.
- #[serde(with = "consensus::serde::With::<Hex>")]
- header: Header,
-
- /// This works but it's little-endian which may be hard to read.
+ /// Use `serde_as_consensus` for any type that implements `encoding::{Decodable, Encodable}`.
///
- /// Integer wrapper types are more readable if they explicitly use a unit.
- #[serde(with = "consensus::serde::With::<Hex>")]
- this: Amount,
+ /// Consensus encode then use hex or binary depending on the serializer.
+ #[serde(with = "bitcoin::serde_as_consensus")]
+ header: Header,
/// `Amount` can use sats or bitcoin (`as_btc`).
#[serde(with = "amount::serde::as_sat")]
- that: Amount,
+ amount: Amount,
/// `FeeRate` can use kilo weight units or virtual bytes, both floor and ceil.
#[serde(with = "fee_rate::serde::as_sat_per_kwu_floor")]
fee_rate: FeeRate,
+
}
fn main() {
let f = Foo {
header: dummy_header(),
- this: Amount::ONE_SAT,
- that: Amount::ONE_BTC,
+ amount: Amount::ONE_BTC,
fee_rate: FeeRate::DUST,
};
diff --git a/bitcoin/src/lib.rs b/bitcoin/src/lib.rs
index 491142ec..8b81d440 100644
--- a/bitcoin/src/lib.rs
+++ b/bitcoin/src/lib.rs
@@ -175,6 +175,9 @@ pub use units::{
time::{self, BlockTime, BlockTimeDecoder, BlockTimeDecoderError},
weight::Weight,
};
+#[doc(inline)]
+#[cfg(feature = "serde")]
+pub use primitives::serde_as_consensus;
#[deprecated(since = "TBD", note = "use `BlockHeightInterval` instead")]
#[doc(hidden)]
diff --git a/primitives/src/block.rs b/primitives/src/block.rs
index 350b31ef..892e4ffe 100644
--- a/primitives/src/block.rs
+++ b/primitives/src/block.rs
@@ -985,6 +985,8 @@ mod tests {
use core::str::FromStr as _;
use encoding::{Decoder, Encoder};
+ #[cfg(all(feature = "serde", feature = "hex", feature = "alloc"))]
+ use serde::{Deserialize, Serialize};
use super::*;
@@ -1780,4 +1782,40 @@ mod tests {
#[cfg(feature = "std")]
assert!(std::error::Error::source(&err).is_some());
}
+
+ /// A type that has a `Block` field and a `Header` field.
+ #[cfg(all(feature = "serde", feature = "hex", feature = "alloc"))]
+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
+ struct Adt {
+ #[serde(with = "crate::serde_as_consensus")]
+ header: Header,
+ #[serde(with = "crate::serde_as_consensus")]
+ block: Block,
+ }
+
+ #[test]
+ #[cfg(all(feature = "serde", feature = "hex", feature = "alloc"))]
+ fn can_serde_as_consensus_json() {
+ let orig = Adt { header: dummy_header(), block: dummy_block() };
+
+ let json = serde_json::to_string(&orig).expect("failed to serialize");
+
+ let want = "{\"header\":\"0100000099999999999999999999999999999999999999999999999999999999999999997777777777777777777777777777777777777777777777777777777777777777020000000300000004000000\",\"block\":\"01000000dcbadcbadcbadcbadcbadcbadcbadcbadcbadcbadcbadcbadcbadcbadcbadcbaabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd10c2e3674e61bc00000400000101000000010000000000000000000000000000000000000000000000000000000000000000ffffffff025151ffffffff0101000000000000000091500b00\"}";
+ assert_eq!(json, want);
+
+ let roundtrip: Adt = serde_json::from_str(&json).expect("failed to deserialize");
+ assert_eq!(roundtrip, orig);
+ }
+
+ #[test]
+ #[cfg(all(feature = "serde", feature = "hex", feature = "alloc"))]
+ fn can_serde_as_consensus_bincode() {
+ let orig = Adt { header: dummy_header(), block: dummy_block() };
+
+ // Bincode is non-human-readable, so it should use bytes
+ let bytes = bincode::serialize(&orig).expect("failed to serialize");
+
+ let roundtrip: Adt = bincode::deserialize(&bytes).expect("failed to deserialize");
+ assert_eq!(roundtrip, orig);
+ }
}
diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs
index faa9e98c..8de200a0 100644
--- a/primitives/src/lib.rs
+++ b/primitives/src/lib.rs
@@ -49,6 +49,8 @@ mod opcodes;
pub mod block;
pub mod merkle_tree;
+#[cfg(all(feature = "serde", feature = "hex", feature = "alloc"))]
+pub mod serde_as_consensus;
#[cfg(feature = "alloc")]
pub mod script;
pub mod transaction;
diff --git a/primitives/src/serde_as_consensus.rs b/primitives/src/serde_as_consensus.rs
new file mode 100644
index 00000000..c24b0901
--- /dev/null
+++ b/primitives/src/serde_as_consensus.rs
@@ -0,0 +1,270 @@
+// SPDX-License-Identifier: CC0-1.0
+
+// Methods are an implementation of a standardized serde-specific signature.
+#![allow(missing_docs)]
+#![allow(clippy::missing_errors_doc)]
+
+//! `serde` serialize and deserialize types using consensus encoding.
+//!
+//! Use with `#[serde(with = "bitcoin_primitives::serde_as_consensus")]`.
+//!
+//! This module works with any type `T` that implements both [`Encodable`] and [`Decodable`].
+//! In human-readable formats (like JSON), the value is serialized as a hex string.
+//! In non-human-readable formats (like bincode), raw bytes are used.
+//!
+//! # Examples
+//!
+//! ```
+//! use serde::{Serialize, Deserialize};
+//! use bitcoin_primitives::block::{Block, Header, Unchecked};
+//! use bitcoin_primitives::TxOut;
+//!
+//! #[derive(Serialize, Deserialize)]
+//! pub struct MyStruct {
+//! // Serialize as hex when using human-readable formats (JSON, etc.)
+//! #[serde(with = "bitcoin_primitives::serde_as_consensus")]
+//! pub header: Header,
+//! // We support options too.
+//! #[serde(with = "bitcoin_primitives::serde_as_consensus::opt")]
+//! pub block: Option<Block<Unchecked>>,
+//! // And we support vectors.
+//! #[serde(with = "bitcoin_primitives::serde_as_consensus::vec")]
+//! pub tx_outs: Vec<TxOut>,
+//! }
+//! ```
+
+use core::fmt;
+use core::marker::PhantomData;
+
+use encoding::{Decodable, Encodable};
+use serde::{de, Deserializer, Serializer};
+
+use crate::hex_codec::HexPrimitive;
+
+/// Serializes a type as a consensus-encoded hex string.
+///
+/// # Type Parameters
+///
+/// * `T` - The type to serialize, must implement [`Encodable`] and [`Decodable`]
+/// * `S` - The serializer type
+pub fn serialize<T, S>(value: &T, s: S) -> Result<S::Ok, S::Error>
+where
+ T: Encodable + Decodable,
+ S: Serializer,
+{
+ if s.is_human_readable() {
+ // `HexPrimitive` uses `LowerHex` for `Display`.
+ s.collect_str(&crate::hex_codec::HexPrimitive(value))
+ } else {
+ // For non-human-readable formats, serialize as bytes.
+ let bytes = encoding::encode_to_vec(value);
+ s.serialize_bytes(&bytes)
+ }
+}
+
+/// Deserializes a type from a consensus-encoded hex string.
+///
+/// # Type Parameters
+///
+/// * `T` - The type to deserialize, must implement [`Encodable`] and [`Decodable`]
+/// * `D` - The deserializer type
+pub fn deserialize<'d, T, D>(d: D) -> Result<T, D::Error>
+where
+ T: Encodable + Decodable,
+ D: Deserializer<'d>,
+{
+ if d.is_human_readable() {
+ use alloc::string::String;
+ use serde::Deserialize;
+
+ let hex_str = String::deserialize(d)?;
+ HexPrimitive::<T>::from_str(&hex_str).map_err(de::Error::custom)
+ } else {
+ // For non-human-readable formats, deserialize from bytes
+ struct BytesVisitor<T>(PhantomData<T>);
+
+ impl<'de, T> serde::de::Visitor<'de> for BytesVisitor<T>
+ where
+ T: Encodable + Decodable,
+ {
+ type Value = T;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("a byte array")
+ }
+
+ fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
+ where
+ E: serde::de::Error,
+ {
+ encoding::decode_from_slice(v)
+ .map_err(|_| serde::de::Error::custom("failed to decode from bytes"))
+ }
+
+ fn visit_byte_buf<E>(self, v: alloc::vec::Vec<u8>) -> Result<Self::Value, E>
+ where
+ E: serde::de::Error,
+ {
+ encoding::decode_from_slice(&v)
+ .map_err(|_| serde::de::Error::custom("failed to decode from bytes"))
+ }
+ }
+
+ d.deserialize_bytes(BytesVisitor(PhantomData))
+ }
+}
+
+pub mod opt {
+ //! `serde` serialize and deserialize `Option<T>`.
+ //!
+ //! **WARNING:**
+ //!
+ //! This module is specifically for using `serde` to be able to serialize any object that is
+ //! `Encodable`/`Decodable` if said object is in an `Option`.
+ //!
+ //! Use with `#[serde(with = "bitcoin_primitives::serde_as_consensus::opt")]`.
+
+ use core::fmt;
+ use core::marker::PhantomData;
+
+ use encoding::{Decodable, Encodable};
+ use serde::{de, Deserializer, Serializer};
+
+ #[allow(clippy::ref_option)] // API forced by serde.
+ pub fn serialize<T, S>(t: &Option<T>, s: S) -> Result<S::Ok, S::Error>
+ where
+ T: Encodable + Decodable,
+ S: Serializer,
+ {
+ struct AsConsensus<'a, T>(&'a T);
+
+ impl<T: Encodable + Decodable> serde::Serialize for AsConsensus<'_, T> {
+ fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
+ super::serialize(self.0, s)
+ }
+ }
+
+ match *t {
+ Some(ref t) => s.serialize_some(&AsConsensus(t)),
+ None => s.serialize_none(),
+ }
+ }
+
+ pub fn deserialize<'d, T, D>(d: D) -> Result<Option<T>, D::Error>
+ where
+ T: Encodable + Decodable,
+ D: Deserializer<'d>,
+ {
+ struct OptVisitor<X>(PhantomData<X>);
+
+ impl<'de, X> de::Visitor<'de> for OptVisitor<X>
+ where
+ X: Encodable + Decodable,
+ {
+ type Value = Option<X>;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ write!(formatter, "an Option<T> where T: encoding::Decodable")
+ }
+
+ fn visit_none<E>(self) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ Ok(None)
+ }
+
+ fn visit_some<D>(self, d: D) -> Result<Self::Value, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ Ok(Some(super::deserialize(d)?))
+ }
+ }
+ d.deserialize_option(OptVisitor::<T>(PhantomData))
+ }
+}
+
+pub mod vec {
+ //! `serde` serialize and deserialize `Vec<T>`.
+ //!
+ //! **WARNING:**
+ //!
+ //! This is not a consensus encoded vector (i.e, with length prefix). This module is
+ //! specifically for using `serde` to be able to serialize any object that is
+ //! `Encodable`/`Decodable` if said object is in a `Vec`.
+ //!
+ //! Use with `#[serde(with = "bitcoin_primitives::serde_as_consensus::vec")]`.
+
+ use alloc::vec::Vec;
+ use core::fmt;
+ use core::marker::PhantomData;
+
+ use encoding::{Decodable, Encodable};
+ use serde::{de, Deserializer, Serializer};
+
+ pub fn serialize<T, S>(v: &[T], s: S) -> Result<S::Ok, S::Error>
+ where
+ T: Encodable + Decodable,
+ S: Serializer,
+ {
+ use serde::ser::SerializeSeq;
+
+ struct AsConsensus<'a, T>(&'a T);
+
+ impl<T: Encodable + Decodable> serde::Serialize for AsConsensus<'_, T> {
+ fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
+ super::serialize(self.0, s)
+ }
+ }
+
+ let mut seq = s.serialize_seq(Some(v.len()))?;
+ for item in v {
+ seq.serialize_element(&AsConsensus(item))?;
+ }
+ seq.end()
+ }
+
+ pub fn deserialize<'d, T, D>(d: D) -> Result<Vec<T>, D::Error>
+ where
+ T: Encodable + Decodable,
+ D: Deserializer<'d>,
+ {
+ struct VecVisitor<X>(PhantomData<X>);
+
+ impl<'de, X> de::Visitor<'de> for VecVisitor<X>
+ where
+ X: Encodable + Decodable,
+ {
+ type Value = Vec<X>;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ write!(formatter, "a sequence of consensus-encodable items")
+ }
+
+ fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+ where
+ A: de::SeqAccess<'de>,
+ {
+ struct Wrap<X>(X);
+
+ impl<'de, X> de::Deserialize<'de> for Wrap<X>
+ where
+ X: Encodable + Decodable,
+ {
+ fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
+ super::deserialize::<X, D>(d).map(Wrap)
+ }
+ }
+
+ let mut out = Vec::new();
+ while let Some(Wrap(item)) = seq.next_element::<Wrap<X>>()? {
+ out.push(item);
+ }
+ Ok(out)
+ }
+ }
+
+ d.deserialize_seq(VecVisitor::<T>(PhantomData))
+ }
+}
Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.