consensus_encoding: Add hex decoding function
What changed, and why it matters
This commit adds a new public helper function that lets users decode Bitcoin consensus data directly from a hexadecimal string, without needing to allocate a temporary byte vector. It is a pure feature addition: it introduces a new error type, exports the function under a feature flag, and includes unit tests. There is no indication in the commit that it fixes a security bug or that the change itself creates one.
No immediate security action required. Treat as a routine feature addition. Reviewers may want to verify that the 4096-byte stack buffer is appropriate for all target environments and that the `hex` feature gating is consistent with documentation.
Security signals we found
No security-relevant signals present in the commit message or diff.
New public API surface increases code that must be maintained securely, but the implementation delegates parsing to existing, well-scoped decoder traits.
Buffer handling uses a fixed 4096-byte stack buffer with explicit flushing; no unbounded allocation or obvious memory-safety issue is visible.
Error paths correctly propagate hex decoding failures and decoder parse/unconsumed errors.
Evidence from the diff
The patch adds decode_from_hex<T: Decode>(hex: &str) to bitcoin-consensus-encoding, gated by the hex feature. It streams hex-decoded bytes through a small 4096-byte stack buffer into the existing Decoder::push_bytes machinery, reusing the same parsing and unconsumed-byte checks as decode_from_slice. A new FromHexError enum wraps hex::OddLengthStringError, hex::InvalidCharError, and DecodeError. The function is exported at the crate root and tested for success, large inputs exceeding the internal buffer, and error cases (odd length, invalid character, parse failure, empty input, trailing bytes).
Changed components
bitcoin-consensus-encoding crateconsensus_encoding/src/decode/mod.rsconsensus_encoding/src/error.rsconsensus_encoding/src/lib.rsconsensus_encoding/tests/decode.rsInspect captured patch +155 / −1
diff --git a/consensus_encoding/src/decode/mod.rs b/consensus_encoding/src/decode/mod.rs
index bdd3c7ff..231ec4ec 100644
--- a/consensus_encoding/src/decode/mod.rs
+++ b/consensus_encoding/src/decode/mod.rs
@@ -4,6 +4,8 @@
pub mod decoders;
+#[cfg(feature = "hex")]
+use crate::FromHexError;
#[cfg(feature = "std")]
use crate::ReadError;
use crate::{DecodeError, UnconsumedError};
@@ -130,6 +132,64 @@ impl DecoderStatus {
pub fn is_ready(&self) -> bool { matches!(self, Self::Ready) }
}
+/// Decodes an object from a hex string without heap allocations.
+///
+/// # Errors
+///
+/// - [`FromHexError::OddLength`] if the string has an odd number of characters.
+/// - [`FromHexError::InvalidChar`] if any character is not a valid hex digit.
+/// - [`FromHexError::Decode`] if decoding the type fails, including if bytes remain unconsumed
+/// after the decoder completes.
+#[cfg(feature = "hex")]
+pub fn decode_from_hex<T: Decode>(
+ hex: &str,
+) -> Result<T, FromHexError<<T::Decoder as Decoder>::Error>> {
+ let iter = hex::HexSliceToBytesIter::new(hex).map_err(FromHexError::OddLength)?;
+
+ let mut decoder = T::decoder();
+ let mut buffer = [0u8; 4096];
+ let mut index = 0;
+
+ for item in iter {
+ let byte = item.map_err(FromHexError::InvalidChar)?;
+
+ if index == buffer.len() {
+ let mut to_flush = buffer.as_slice();
+ // There is at least a single byte left after flushing the buffer. Error if the decoder
+ // is ready after flush.
+ while !to_flush.is_empty() {
+ if decoder
+ .push_bytes(&mut to_flush)
+ .map_err(|e| FromHexError::Decode(DecodeError::Parse(e)))?
+ .is_ready()
+ {
+ return Err(FromHexError::Decode(DecodeError::Unconsumed(UnconsumedError())));
+ }
+ }
+ index = 0;
+ }
+ buffer[index] = byte;
+ index += 1;
+ }
+
+ let mut to_flush = &buffer[..index];
+ while !to_flush.is_empty() {
+ if decoder
+ .push_bytes(&mut to_flush)
+ .map_err(|e| FromHexError::Decode(DecodeError::Parse(e)))?
+ .is_ready()
+ {
+ break;
+ }
+ }
+
+ if to_flush.is_empty() {
+ decoder.end().map_err(|e| FromHexError::Decode(DecodeError::Parse(e)))
+ } else {
+ Err(FromHexError::Decode(DecodeError::Unconsumed(UnconsumedError())))
+ }
+}
+
/// Decodes an object from a byte slice.
///
/// # Errors
diff --git a/consensus_encoding/src/error.rs b/consensus_encoding/src/error.rs
index 1f8c4268..4af7c7bc 100644
--- a/consensus_encoding/src/error.rs
+++ b/consensus_encoding/src/error.rs
@@ -325,6 +325,49 @@ impl std::error::Error for UnexpectedEofError {
}
}
+/// An error that can occur when decoding from a hex string.
+#[cfg(feature = "hex")]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum FromHexError<ParseErr> {
+ /// The hex string had an odd number of characters.
+ OddLength(hex::OddLengthStringError),
+ /// A character in the hex string was not a valid hex digit.
+ InvalidChar(hex::InvalidCharError),
+ /// The decoder rejected the decoded bytes, or bytes remained unconsumed after decoding.
+ Decode(DecodeError<ParseErr>),
+}
+
+#[cfg(feature = "hex")]
+impl<ParseErr> From<Infallible> for FromHexError<ParseErr> {
+ fn from(never: Infallible) -> Self { match never {} }
+}
+
+#[cfg(feature = "hex")]
+impl<ParseErr: fmt::Display> fmt::Display for FromHexError<ParseErr> {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match *self {
+ Self::OddLength(ref e) => write_err!(f, "odd length string"; e),
+ Self::InvalidChar(ref e) => write_err!(f, "invalid character"; e),
+ Self::Decode(ref e) => write_err!(f, "decode error"; e),
+ }
+ }
+}
+
+#[cfg(feature = "hex")]
+#[cfg(feature = "std")]
+impl<ParseErr> std::error::Error for FromHexError<ParseErr>
+where
+ ParseErr: std::error::Error + 'static,
+{
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match *self {
+ Self::OddLength(ref e) => Some(e),
+ Self::InvalidChar(ref e) => Some(e),
+ Self::Decode(ref e) => Some(e),
+ }
+ }
+}
+
/// Helper macro to define an error type for a `DecoderN`.
macro_rules! define_decoder_n_error {
(
diff --git a/consensus_encoding/src/lib.rs b/consensus_encoding/src/lib.rs
index e807a6dd..aad8531f 100644
--- a/consensus_encoding/src/lib.rs
+++ b/consensus_encoding/src/lib.rs
@@ -54,7 +54,8 @@
//!
//! * `std` - Enables std lib I/O driver functions and `std::error::Error` impls (implies `alloc`).
//! * `alloc` - Enables [`encode_to_vec`], `Vec`-based decoders, and allocation-based helpers.
-//! * `hex` - Enables [`encode_to_hex`] and [`drain_to_hex`] (also requires `alloc`).
+//! * `hex` - Enables [`decode_from_hex`], [`encode_to_hex`] and [`drain_to_hex`]. Encoding also
+//! requires `alloc`.
#![no_std]
// Coding conventions.
@@ -75,6 +76,9 @@ pub mod error;
#[doc(inline)]
pub use self::compact_size::{CompactSizeDecoder, CompactSizeEncoder, CompactSizeU64Decoder};
+#[cfg(feature = "hex")]
+#[doc(inline)]
+pub use self::decode::decode_from_hex;
#[doc(inline)]
pub use self::decode::decoders::{ArrayDecoder, Decoder2, Decoder3, Decoder4, Decoder6};
#[cfg(feature = "alloc")]
@@ -109,6 +113,9 @@ pub use self::encode::{drain_to_vec, encode_to_vec};
#[cfg(feature = "std")]
#[doc(inline)]
pub use self::encode::{drain_to_writer, encode_to_writer};
+#[cfg(feature = "hex")]
+#[doc(no_inline)]
+pub use self::error::FromHexError;
#[cfg(feature = "alloc")]
#[doc(no_inline)]
pub use self::error::LengthPrefixExceedsMaxError;
diff --git a/consensus_encoding/tests/decode.rs b/consensus_encoding/tests/decode.rs
index 69e16b33..79f2d918 100644
--- a/consensus_encoding/tests/decode.rs
+++ b/consensus_encoding/tests/decode.rs
@@ -11,6 +11,8 @@ use bitcoin_consensus_encoding::{
check_decoder, decode_from_slice, decode_from_slice_unbounded, ArrayDecoder,
CompactSizeDecoder, Decode, DecodeError, Decoder, Decoder2, UnexpectedEofError,
};
+#[cfg(feature = "hex")]
+use bitcoin_consensus_encoding::{decode_from_hex, FromHexError};
#[cfg(feature = "std")]
use bitcoin_consensus_encoding::{decode_from_read, decode_from_read_unbuffered, ReadError};
#[cfg(feature = "alloc")]
@@ -297,6 +299,48 @@ fn decode_from_slice_unbounded_extra_data() {
assert_eq!(bytes.len(), 1);
}
+#[test]
+#[cfg(feature = "hex")]
+fn decode_from_hex_test() {
+ let result: Result<TestArray, _> = decode_from_hex("01020304");
+ assert_eq!(result.unwrap().0, [0x01, 0x02, 0x03, 0x04]);
+ let result: Result<TestArray, _> = decode_from_hex("DEADBEEF");
+ assert_eq!(result.unwrap().0, [0xDE, 0xAD, 0xBE, 0xEF]);
+}
+
+#[test]
+#[cfg(all(feature = "hex", feature = "alloc"))]
+fn decode_from_hex_larger_than_internal_buffer() {
+ const COUNT: usize = 1100;
+
+ let mut encoded = vec![0xFD, 0x4C, 0x04];
+ encoded.extend(core::iter::repeat(0xDEAD_BEEF_u32.to_le_bytes()).take(COUNT).flatten());
+ assert!(encoded.len() > 4096);
+
+ let mut hex = String::with_capacity(encoded.len() * 2);
+ for byte in &encoded {
+ hex.push_str(&format!("{:02x}", byte));
+ }
+
+ let result: Result<Test, _> = decode_from_hex(&hex);
+ assert_eq!(result.unwrap(), Test(vec![Inner(0xDEAD_BEEF); COUNT]));
+}
+
+#[test]
+#[cfg(feature = "hex")]
+fn decode_from_hex_error() {
+ let result: Result<TestArray, _> = decode_from_hex("0102030");
+ assert!(matches!(result, Err(FromHexError::OddLength(_))));
+ let result: Result<TestArray, _> = decode_from_hex("0102GG04");
+ assert!(matches!(result, Err(FromHexError::InvalidChar(_))));
+ let result: Result<TestArray, _> = decode_from_hex("0102");
+ assert!(matches!(result, Err(FromHexError::Decode(DecodeError::Parse(_)))));
+ let result: Result<TestArray, _> = decode_from_hex("");
+ assert!(matches!(result, Err(FromHexError::Decode(DecodeError::Parse(_)))));
+ let result: Result<TestArray, _> = decode_from_hex("0102030405060708");
+ assert!(matches!(result, Err(FromHexError::Decode(DecodeError::Unconsumed(_)))));
+}
+
#[test]
#[cfg(feature = "std")]
fn decode_from_read_extra_data() {
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.