Skip to main content

thruput_xdr/
base58.rs

1//! Allocation-free Bitcoin/Solana base58 conversion for identifier bytes.
2//!
3//! These helpers do not select an encoding based on byte length. Public API
4//! adapters select identifier fields; opaque payloads continue to use hex.
5//! StrKey input policy belongs to higher-level clients.
6
7use core::fmt;
8
9/// A conversion failure. Neither formatting nor debug output includes input.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Error {
13    /// The output buffer cannot hold the encoded text or decoded bytes.
14    ///
15    /// [`decode_fixed`] also reports input that decodes to more than its
16    /// `N` bytes this way, since it decodes into an `N`-byte buffer.
17    BufferTooSmall,
18    /// The input contains a character outside the case-sensitive alphabet.
19    InvalidEncoding,
20    /// Decoding succeeded but the identifier has the wrong byte length.
21    ///
22    /// [`decode_fixed`] reports only too-short input this way; too-long
23    /// input is [`Error::BufferTooSmall`].
24    InvalidLength {
25        /// Required byte length.
26        expected: usize,
27        /// Decoded byte length.
28        actual: usize,
29    },
30}
31
32impl fmt::Display for Error {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::BufferTooSmall => {
36                f.write_str("base58 output buffer is too small")
37            }
38            Self::InvalidEncoding => f.write_str("invalid base58 encoding"),
39            Self::InvalidLength { expected, actual } => {
40                write!(f, "expected {expected} bytes, received {actual}")
41            }
42        }
43    }
44}
45
46impl core::error::Error for Error {}
47
48/// Conservative text bound for a decoded byte limit; saturates on overflow.
49///
50/// A value within this bound can still exceed the decoded limit. Empty bytes
51/// encode as empty text. The ratio 1.38 exceeds log(256) / log(58).
52pub const fn max_encoded_len(max_bytes: usize) -> usize {
53    if max_bytes == 0 {
54        return 0;
55    }
56    (max_bytes / 100)
57        .saturating_mul(138)
58        .saturating_add((max_bytes % 100) * 138 / 100)
59        .saturating_add(1)
60}
61
62/// Encode bytes into the start of `output`, returning the written length.
63///
64/// Preserves leading zeros and adds no prefix. A buffer of
65/// [`max_encoded_len(bytes.len())`](max_encoded_len) bytes is sufficient.
66/// On error, the output buffer may have been modified.
67pub fn encode_into(bytes: &[u8], output: &mut [u8]) -> Result<usize, Error> {
68    bs58::encode(bytes)
69        .onto(output)
70        .map_err(|_| Error::BufferTooSmall)
71}
72
73/// Decode into the start of `output`, returning the written byte length.
74///
75/// Rejects whitespace and invalid characters without echoing the input. The
76/// buffer length bounds decoded size and is used to reject oversized text
77/// before conversion. Conversion has quadratic cost; use a field-appropriate
78/// buffer size for untrusted input. On error, the buffer may be modified.
79pub fn decode_into(text: &str, output: &mut [u8]) -> Result<usize, Error> {
80    if text.len() > max_encoded_len(output.len()) {
81        return Err(Error::BufferTooSmall);
82    }
83    bs58::decode(text)
84        .onto(output)
85        .map_err(|error| match error {
86            bs58::decode::Error::BufferTooSmall => Error::BufferTooSmall,
87            _ => Error::InvalidEncoding,
88        })
89}
90
91/// Decode an identifier of exactly `N` bytes, with no heap allocation.
92///
93/// Input that decodes to fewer than `N` bytes is [`Error::InvalidLength`];
94/// input that decodes to more is [`Error::BufferTooSmall`].
95///
96/// ```
97/// use thruput_xdr::base58::decode_fixed;
98/// assert_eq!(decode_fixed::<4>("119p")?, [0, 0, 1, 255]);
99/// # Ok::<(), thruput_xdr::base58::Error>(())
100/// ```
101pub fn decode_fixed<const N: usize>(text: &str) -> Result<[u8; N], Error> {
102    let mut bytes = [0; N];
103    let actual = decode_into(text, &mut bytes)?;
104    if actual != N {
105        return Err(Error::InvalidLength {
106            expected: N,
107            actual,
108        });
109    }
110    Ok(bytes)
111}
112
113/// Borrowed fixed-size bytes formatted as unprefixed base58.
114///
115/// Construct with [`base58_encode`]. Formatting uses `2 * N` bytes of stack
116/// scratch space and no heap allocation; intended for small identifiers.
117pub struct Base58<'a, const N: usize>(&'a [u8; N]);
118
119impl<const N: usize> fmt::Display for Base58<'_, N> {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        // Two characters per byte is sufficient even for a single byte.
122        // Nested arrays keep the buffer size tied to N on stable Rust.
123        let mut buffer = [[0u8; 2]; N];
124        let buffer = buffer.as_flattened_mut();
125        let len = encode_into(self.0, buffer).map_err(|_| fmt::Error)?;
126        let text =
127            core::str::from_utf8(&buffer[..len]).map_err(|_| fmt::Error)?;
128        f.write_str(text)
129    }
130}
131
132impl<const N: usize> fmt::Debug for Base58<'_, N> {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        fmt::Display::fmt(self, f)
135    }
136}
137
138/// Wrap fixed-size identifier bytes for allocation-free base58 formatting.
139///
140/// ```
141/// use thruput_xdr::base58::base58_encode;
142/// assert_eq!(format!("{}", base58_encode(&[0, 0, 1, 255])), "119p");
143/// ```
144pub fn base58_encode<const N: usize>(bytes: &[u8; N]) -> Base58<'_, N> {
145    Base58(bytes)
146}
147
148#[cfg(test)]
149mod tests;