Skip to main content

thruput_encoding/
lib.rs

1//! Byte-preserving text codecs. Network identity and account derivation are separate.
2//!
3//! Machine interfaces use base58 only. The typed human-input helpers also accept
4//! Thruput Z addresses and Stellar G/S `StrKeys`. No function guesses that an
5//! arbitrary byte array is an address. Z identifies Thruput, not a specific network.
6
7use thruput_xdr::base58;
8use zeroize::Zeroizing;
9
10pub use base58::max_encoded_len;
11
12// Top five bits select Z in RFC 4648 base32; the low three bits are zero.
13const ADDRESS_STRKEY_VERSION: u8 = 25 << 3;
14
15/// Invalid text or a violated byte-length limit. Never contains the supplied text.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
17pub enum Error {
18    /// The input contains a character outside the Bitcoin/Solana alphabet.
19    #[error("invalid base58 encoding")]
20    InvalidBase58,
21    /// Conversion would exceed the caller's decoded byte limit.
22    #[error("base58 value exceeds the {max_bytes}-byte limit")]
23    TooLong {
24        /// Caller-specified decoded byte limit.
25        max_bytes: usize,
26    },
27    /// A fixed-size field decoded to an unexpected byte length.
28    #[error("expected {expected} bytes, received {actual}")]
29    InvalidLength {
30        /// Field's required byte length.
31        expected: usize,
32        /// Actual decoded length.
33        actual: usize,
34    },
35    /// A typed human input was neither the expected `StrKey` nor a base58 value.
36    #[error("expected a 32-byte base58 value or a valid {prefix} StrKey")]
37    InvalidTypedValue {
38        /// Expected `StrKey` prefix.
39        prefix: char,
40    },
41}
42
43/// Encode using the case-sensitive Bitcoin/Solana alphabet, preserving leading zeros.
44pub fn encode(bytes: &[u8]) -> String {
45    let mut output = vec![0; max_encoded_len(bytes.len())];
46    let len = base58::encode_into(bytes, &mut output)
47        .expect("bug: base58 encoded length exceeds its bound");
48    output.truncate(len);
49    String::from_utf8(output).expect("bug: base58 encoding is not ASCII")
50}
51
52fn decode_error(error: base58::Error, max_bytes: usize) -> Error {
53    match error {
54        base58::Error::BufferTooSmall => Error::TooLong { max_bytes },
55        base58::Error::InvalidLength { expected, actual } => {
56            Error::InvalidLength { expected, actual }
57        }
58        _ => Error::InvalidBase58,
59    }
60}
61
62/// Decode a machine value with a required maximum byte length.
63///
64/// Checks encoded length before conversion, bounds allocation by the input and
65/// decoded limit, and rejects excess decoded bytes. Whitespace is not trimmed.
66/// The caller must select a field-appropriate limit; this is not a CPU time limit.
67pub fn decode(text: &str, max_bytes: usize) -> Result<Vec<u8>, Error> {
68    if text.len() > max_encoded_len(max_bytes) {
69        return Err(Error::TooLong { max_bytes });
70    }
71    // A base58 character contributes at most one decoded byte (including zeros).
72    let mut output = vec![0; text.len().min(max_bytes)];
73    let len =
74        base58::decode_into(text, &mut output).map_err(|error| decode_error(error, max_bytes))?;
75    output.truncate(len);
76    Ok(output)
77}
78
79/// Decode exactly N bytes without a heap-allocated intermediate byte buffer.
80pub fn decode_fixed<const N: usize>(text: &str) -> Result<[u8; N], Error> {
81    base58::decode_fixed(text).map_err(|error| decode_error(error, N))
82}
83
84fn decode_fixed_into(text: &str, output: &mut [u8]) -> Result<(), Error> {
85    let actual =
86        base58::decode_into(text, output).map_err(|error| decode_error(error, output.len()))?;
87    if actual != output.len() {
88        return Err(Error::InvalidLength {
89            expected: output.len(),
90            actual,
91        });
92    }
93    Ok(())
94}
95
96/// Parse a Thruput address from base58 or Z `StrKey`, without deriving an account.
97///
98/// Stellar C addresses are rejected, even when they encode the same bytes.
99pub fn parse_address(text: &str) -> Result<[u8; 32], Error> {
100    if text.len() == 56
101        && let Ok((version, payload)) = stellar_strkey::convert::decode::<32, 35>(text.as_bytes())
102        && version == ADDRESS_STRKEY_VERSION
103        && let Ok(bytes) = <[u8; 32]>::try_from(payload.as_slice())
104        && address_strkey(&bytes) == text
105    {
106        return Ok(bytes);
107    }
108    decode_fixed(text).map_err(|_| Error::InvalidTypedValue { prefix: 'Z' })
109}
110
111/// Parse an Ed25519 public key from base58 or G `StrKey`, never a wallet address.
112pub fn parse_public_key(text: &str) -> Result<[u8; 32], Error> {
113    if let Ok(key) = text.parse::<stellar_strkey::ed25519::PublicKey>() {
114        return Ok(key.0);
115    }
116    decode_fixed(text).map_err(|_| Error::InvalidTypedValue { prefix: 'G' })
117}
118
119/// Parse a 32-byte Ed25519 seed from base58 or S `StrKey`.
120///
121/// Decoding buffers are zeroed on drop. Callers must also protect their input text.
122pub fn parse_secret_seed(text: &str) -> Result<Zeroizing<[u8; 32]>, Error> {
123    if let Ok(key) = text.parse::<stellar_strkey::ed25519::PrivateKey>() {
124        return Ok(Zeroizing::new(key.0));
125    }
126    let mut output = Zeroizing::new([0; 32]);
127    decode_fixed_into(text, output.as_mut_slice())
128        .map_err(|_| Error::InvalidTypedValue { prefix: 'S' })?;
129    Ok(output)
130}
131
132/// Format the same address bytes as a Thruput Z `StrKey`.
133///
134/// The prefix does not distinguish local, test, and production networks.
135pub fn address_strkey(bytes: &[u8; 32]) -> String {
136    stellar_strkey::convert::encode::<32, 35, 56>(ADDRESS_STRKEY_VERSION, bytes).to_string()
137}
138
139/// Format an Ed25519 key as a G `StrKey`. No wallet derivation is performed.
140pub fn public_key_strkey(bytes: &[u8; 32]) -> String {
141    stellar_strkey::ed25519::PublicKey(*bytes)
142        .to_string()
143        .as_str()
144        .to_owned()
145}
146
147/// Explicitly export an Ed25519 seed as an S `StrKey` in a zeroing buffer.
148pub fn secret_seed_strkey(bytes: &[u8; 32]) -> Zeroizing<String> {
149    let key = stellar_strkey::ed25519::PrivateKey(*bytes);
150    let mut output = Zeroizing::default();
151    key.as_unredacted().write_string(&mut output);
152    Zeroizing::new(output.as_str().to_owned())
153}
154
155#[cfg(test)]
156mod tests;