Skip to main content

md_codec/
codex32.rs

1//! v0.11 ↔ codex32 BCH layer adapter, symbol-aligned per spec §3.1 / D7.
2//!
3//! Bypasses v0.x's byte-oriented `encode_string` / `decode_string` to avoid
4//! adding an extra codex32 char per encoding due to byte-padding. Uses v0.x's
5//! lower-level BCH primitives (`bch_create_checksum_regular`,
6//! `bch_verify_regular`) which operate on `&[u8]` slices of 5-bit symbols.
7
8use crate::bitstream::{BitReader, BitWriter};
9use crate::error::Error;
10
11/// Codex32 alphabet (BIP 173 lowercase). Each char = one 5-bit symbol.
12const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
13
14/// HRP for v0.11 (matches v0.x).
15const HRP: &str = "md";
16
17/// Regular-BCH checksum length, in 5-bit symbols.
18pub(crate) const REGULAR_CHECKSUM_SYMBOLS: usize = 13;
19
20/// Pack `bit_count` bits from `payload_bytes` into 5-bit symbols. Pads the
21/// final symbol with zeros if `bit_count` is not a multiple of 5. Returns
22/// `ceil(bit_count / 5)` symbols. Each output u8 contains a 5-bit value.
23fn bits_to_symbols(payload_bytes: &[u8], bit_count: usize) -> Result<Vec<u8>, Error> {
24    let symbol_count = bit_count.div_ceil(5);
25    let mut r = BitReader::with_bit_limit(payload_bytes, bit_count);
26    let mut symbols = Vec::with_capacity(symbol_count);
27    for _ in 0..symbol_count {
28        let take = r.remaining_bits().min(5);
29        let val = if take == 0 {
30            0
31        } else {
32            r.read_bits(take)? as u8
33        };
34        // Left-justify within 5 bits if final symbol is short. (For decoder
35        // round-trip purposes the spec defines bit-packing MSB-first into
36        // 5-bit symbols, so zero-padding the LOW bits of the final symbol is
37        // the canonical form.)
38        let symbol = (val << (5 - take as u32)) & 0x1F;
39        symbols.push(symbol);
40    }
41    Ok(symbols)
42}
43
44/// Convert a stream of 5-bit symbols back into byte-padded bytes (MSB-first).
45fn symbols_to_bytes(symbols: &[u8]) -> Vec<u8> {
46    let mut w = BitWriter::new();
47    for &s in symbols {
48        w.write_bits((s & 0x1F) as u64, 5);
49    }
50    w.into_bytes()
51}
52
53fn symbol_to_char(s: u8) -> char {
54    CODEX32_ALPHABET[(s & 0x1F) as usize] as char
55}
56
57fn char_to_symbol(c: char) -> Option<u8> {
58    let lc = c.to_ascii_lowercase() as u8;
59    CODEX32_ALPHABET
60        .iter()
61        .position(|&b| b == lc)
62        .map(|i| i as u8)
63}
64
65/// Wrap a v0.11 payload bit stream (byte-padded with exact `bit_count`)
66/// into a complete codex32 md1 string with HRP and BCH checksum, symbol-aligned.
67pub fn wrap_payload(payload_bytes: &[u8], bit_count: usize) -> Result<String, Error> {
68    let data_symbols = bits_to_symbols(payload_bytes, bit_count)?;
69    // v0.x exposes `bch_create_checksum_regular(hrp: &str, data: &[u8]) -> [u8; 13]`.
70    let checksum: [u8; 13] = crate::bch::bch_create_checksum_regular(HRP, &data_symbols);
71
72    let mut s =
73        String::with_capacity(HRP.len() + 1 + data_symbols.len() + REGULAR_CHECKSUM_SYMBOLS);
74    s.push_str(HRP);
75    s.push('1'); // BIP 173-style HRP separator
76    for sym in &data_symbols {
77        s.push(symbol_to_char(*sym));
78    }
79    for sym in checksum.iter() {
80        s.push(symbol_to_char(*sym));
81    }
82    Ok(s)
83}
84
85/// BIP-173: a bech32/codex32 string must NOT mix upper and lower case. Returns
86/// true iff `s` (ignoring `-`/whitespace separators + digits, which are
87/// case-neutral) contains BOTH an ASCII-uppercase AND an ASCII-lowercase letter.
88/// All-upper, all-lower, and no-letters are fine. Mirrors mk-codec's
89/// `case_check` (`string_layer/bch.rs`); shared with `chunk::parse_chunk_symbols`.
90pub(crate) fn is_mixed_case(s: &str) -> bool {
91    let mut has_upper = false;
92    let mut has_lower = false;
93    for c in s.chars() {
94        if c.is_ascii_uppercase() {
95            has_upper = true;
96        } else if c.is_ascii_lowercase() {
97            has_lower = true;
98        }
99        if has_upper && has_lower {
100            return true;
101        }
102    }
103    false
104}
105
106/// Unwrap a v0.11 md1 string into (byte-padded payload bytes, symbol-aligned bit count).
107///
108/// The returned `symbol_aligned_bit_count = 5 × data_symbol_count`. This is
109/// the EXACT bit length carried by the codex32 BCH layer (rounded up to the
110/// next 5-bit boundary from the actual payload). The caller uses this as
111/// `decode_payload`'s `bit_len` so the v11 decoder's TLV-rollback only sees
112/// ≤4 bits of trailing zero-padding (well under the 7-bit threshold).
113pub fn unwrap_string(s: &str) -> Result<(Vec<u8>, usize), Error> {
114    // BIP-173: reject mixed-case input (all-upper / all-lower both OK, the
115    // latter canonicalized below). md-codec was the one constellation codec
116    // that leniently accepted mixed case; mk-codec + ms-codec reject it.
117    if is_mixed_case(s) {
118        return Err(Error::Codex32DecodeError(
119            "string mixes upper and lower case (BIP-173 forbids mixed case)".to_string(),
120        ));
121    }
122    // 1. Strip HRP + separator.
123    let prefix = format!("{}1", HRP);
124    if !s.to_ascii_lowercase().starts_with(&prefix) {
125        return Err(Error::Codex32DecodeError(format!(
126            "string does not start with HRP {prefix}"
127        )));
128    }
129    let symbols_str = &s[prefix.len()..];
130
131    // 2. Char-to-symbol decode (tolerate visual separators per D11).
132    let mut symbols = Vec::with_capacity(symbols_str.len());
133    for c in symbols_str.chars() {
134        if c.is_whitespace() || c == '-' {
135            continue;
136        }
137        let sym = char_to_symbol(c).ok_or_else(|| {
138            Error::Codex32DecodeError(format!("character {c:?} not in codex32 alphabet"))
139        })?;
140        symbols.push(sym);
141    }
142
143    // 3. BCH-verify.
144    if !crate::bch::bch_verify_regular(HRP, &symbols) {
145        return Err(Error::Codex32DecodeError(
146            "BCH checksum verification failed".into(),
147        ));
148    }
149
150    // 4. Strip the 13-symbol checksum.
151    if symbols.len() < REGULAR_CHECKSUM_SYMBOLS {
152        return Err(Error::Codex32DecodeError(
153            "string too short for BCH checksum".into(),
154        ));
155    }
156    let data_symbols = &symbols[..symbols.len() - REGULAR_CHECKSUM_SYMBOLS];
157    let bit_count = 5 * data_symbols.len();
158
159    // 5. Convert symbols → byte-padded bytes.
160    Ok((symbols_to_bytes(data_symbols), bit_count))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn wrap_unwrap_round_trip_57_bits() {
169        // Synthetic 57-bit payload (mimics BIP 84 single-sig length).
170        let mut w = BitWriter::new();
171        w.write_bits(0xDEAD_BEEF_CAFE_BABE_u64 >> 7, 57);
172        let bytes = w.into_bytes();
173        let s = wrap_payload(&bytes, 57).unwrap();
174        // HRP "md1" (3 chars) + 12 data symbols + 13 checksum = 28 chars.
175        assert_eq!(s.len(), 28);
176        assert!(s.starts_with("md1"));
177        let (out_bytes, out_bits) = unwrap_string(&s).unwrap();
178        // Symbol-aligned bit count = 5 * 12 = 60 (≥ 57 by ≤4 padding bits).
179        assert_eq!(out_bits, 60);
180        // First 7 bytes match exactly; last byte's high bits match (low bits = padding).
181        assert_eq!(&out_bytes[..7], &bytes[..7]);
182        assert_eq!(out_bytes[7] & 0x80, bytes[7] & 0x80);
183    }
184
185    /// Critical: covers an N-byte chunk whose round-trip would mismatch under
186    /// byte-aligned `bytes.len() * 8` accounting. N=3 is the smallest such case
187    /// (encoder writes 8 bytes; symbol-aligned packing produces 13 symbols which
188    /// unpack to 9 bytes — but symbol_aligned_bit_count = 65 stays the right
189    /// reference).
190    #[test]
191    fn wrap_unwrap_n3_chunk_byte_count_recovers_correctly() {
192        // Chunk-format wire: 37-bit header + 8*3 = 24-bit payload = 61 bits.
193        let bit_count = 37 + 24;
194        let mut w = BitWriter::new();
195        w.write_bits(0x1FFF_FFFF_FFFF_u64, 37); // arbitrary header bits
196        w.write_bits(0x00AA_BBCC_u64, 24);
197        let bytes = w.into_bytes();
198        assert_eq!(bytes.len(), 8); // ceil(61/8)
199        let s = wrap_payload(&bytes, bit_count).unwrap();
200        let (_out_bytes, out_bits) = unwrap_string(&s).unwrap();
201        // Symbol-aligned bit count = 5 * ceil(61/5) = 5 * 13 = 65.
202        assert_eq!(out_bits, 65);
203        // (out_bits - 37) / 8 = (65 - 37) / 8 = 3 → 3 chunk-payload bytes recovered.
204        let recovered_payload_byte_count = (out_bits - 37) / 8;
205        assert_eq!(recovered_payload_byte_count, 3);
206    }
207
208    #[test]
209    fn unwrap_rejects_non_md_string() {
210        assert!(unwrap_string("xx1qpz9r4cy7").is_err());
211    }
212
213    #[test]
214    fn unwrap_tolerates_visual_separators() {
215        let mut w = BitWriter::new();
216        w.write_bits(0b1010, 4);
217        let bytes = w.into_bytes();
218        let s = wrap_payload(&bytes, 4).unwrap();
219        let mut grouped = String::new();
220        for (i, c) in s.chars().enumerate() {
221            grouped.push(c);
222            if i == 3 {
223                grouped.push('-');
224            }
225            if i == 8 {
226                grouped.push(' ');
227            }
228        }
229        let _ = unwrap_string(&grouped).unwrap();
230    }
231}