Skip to main content

mk_codec/string_layer/
bch.rs

1//! BCH primitives for the mk1 string layer: bech32 alphabet conversion and
2//! syndrome-based error correction.
3//!
4//! Forked from `md-codec` v0.4.x (`crates/md-codec/src/encoding.rs`) at the
5//! start of the mk1 v0.1 implementation per `design/DECISIONS.md` D-13. The
6//! BCH polynomials and field arithmetic are shared with the sibling md1
7//! format (both reuse BIP 93's `BCH(93,80,8)` regular code and
8//! `BCH(108,93,8)` long code); the only mk1-specific knobs are the HRP
9//! (`"mk"`) and the NUMS-derived target residues ([`crate::consts::MK_REGULAR_CONST`]
10//! / [`crate::consts::MK_LONG_CONST`]).
11//!
12//! Unlike md-codec's encoding module, this file does **not** expose a
13//! top-level `encode_string` / `decode_string`: mk1's string-layer header
14//! lives at the 5-bit symbol layer (per closure Q-5 — 2 symbols for
15//! `SingleString`, 8 symbols for `Chunked`) rather than the byte-aligned
16//! layer md1 uses. The mk1 `string_layer/mod.rs` builds string-level
17//! encode/decode on top of the BCH primitives here.
18
19use super::bch_decode;
20use crate::consts::{HRP, MK_LONG_CONST, MK_REGULAR_CONST};
21
22/// Which BCH code variant a string uses.
23///
24/// Determined by the total data-part length: regular for ≤93 chars,
25/// long for 96–108 chars. Lengths 94–95 are reserved-invalid.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum BchCode {
28    /// Regular code: BCH(93,80,8). 13-char checksum.
29    Regular,
30    /// Long code: BCH(108,93,8). 15-char checksum.
31    Long,
32}
33
34/// The bech32 32-character alphabet, in 5-bit-value order.
35///
36/// `q=0, p=1, z=2, r=3, y=4, 9=5, x=6, 8=7, g=8, f=9, 2=10, t=11, v=12,
37///  d=13, w=14, 0=15, s=16, 3=17, j=18, n=19, 5=20, 4=21, k=22, h=23,
38///  c=24, e=25, 6=26, m=27, u=28, a=29, 7=30, l=31`.
39pub const ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
40
41/// Inverse lookup: char (lowercase ASCII) -> 5-bit value, or 0xFF if not in alphabet.
42const ALPHABET_INV: [u8; 128] = build_alphabet_inv();
43
44const fn build_alphabet_inv() -> [u8; 128] {
45    let mut inv = [0xFFu8; 128];
46    let mut i = 0;
47    while i < 32 {
48        inv[ALPHABET[i] as usize] = i as u8;
49        i += 1;
50    }
51    inv
52}
53
54/// Convert a sequence of 8-bit bytes to a sequence of 5-bit values
55/// (padded with zero bits at the end if the bit count is not a multiple of 5).
56pub fn bytes_to_5bit(bytes: &[u8]) -> Vec<u8> {
57    let mut acc: u32 = 0;
58    let mut bits = 0u32;
59    let mut out = Vec::with_capacity((bytes.len() * 8).div_ceil(5));
60    for &b in bytes {
61        acc = (acc << 8) | b as u32;
62        bits += 8;
63        while bits >= 5 {
64            bits -= 5;
65            out.push(((acc >> bits) & 0x1F) as u8);
66        }
67    }
68    if bits > 0 {
69        out.push(((acc << (5 - bits)) & 0x1F) as u8);
70    }
71    out
72}
73
74/// Convert a sequence of 5-bit values back to 8-bit bytes.
75///
76/// Returns `None` if any value in `values` is ≥ 32 (out of 5-bit range),
77/// or if the trailing padding bits are non-zero.
78pub fn five_bit_to_bytes(values: &[u8]) -> Option<Vec<u8>> {
79    let mut acc: u32 = 0;
80    let mut bits = 0u32;
81    let mut out = Vec::with_capacity(values.len() * 5 / 8);
82    for &v in values {
83        if v >= 32 {
84            return None;
85        }
86        acc = (acc << 5) | v as u32;
87        bits += 5;
88        if bits >= 8 {
89            bits -= 8;
90            out.push(((acc >> bits) & 0xFF) as u8);
91        }
92    }
93    // Any remaining bits must be zero (padding).
94    if bits >= 5 {
95        return None;
96    }
97    if (acc & ((1 << bits) - 1)) != 0 {
98        return None;
99    }
100    Some(out)
101}
102
103/// The bech32 separator character between HRP and data-part (BIP 173 §3).
104///
105/// Re-exported by [`crate::consts::HRP`] is `"mk"`; this module's
106/// BCH-checksum helpers consume the HRP through their `hrp` parameter so
107/// that the same primitives can verify any single-HRP codex32-derived
108/// string. Production callers MUST pass [`crate::consts::HRP`].
109pub const SEPARATOR: char = '1';
110
111/// Determine the BchCode variant from a total data-part length.
112///
113/// Boundaries are from BIP 93 (codex32): regular code `BCH(93,80,8)` caps at 93,
114/// long code `BCH(108,93,8)` runs 96–108, and lengths 94–95 are explicitly
115/// reserved-invalid to prevent ambiguity in code-variant selection. Lengths
116/// below 14 or above 108 are also rejected.
117pub fn bch_code_for_length(data_part_len: usize) -> Option<BchCode> {
118    match data_part_len {
119        14..=93 => Some(BchCode::Regular),
120        94..=95 => None,
121        96..=108 => Some(BchCode::Long),
122        _ => None,
123    }
124}
125
126/// Check whether a string is all-lowercase, all-uppercase, or mixed.
127///
128/// Only ASCII letters are considered; non-ASCII characters (digits, punctuation,
129/// Unicode letters) are treated as neither case. This is appropriate for MD
130/// strings, whose alphabet is a subset of ASCII. An empty string or one with
131/// no ASCII letters returns [`CaseStatus::Lower`].
132pub fn case_check(s: &str) -> CaseStatus {
133    let mut has_lower = false;
134    let mut has_upper = false;
135    for c in s.chars() {
136        if c.is_ascii_lowercase() {
137            has_lower = true;
138        } else if c.is_ascii_uppercase() {
139            has_upper = true;
140        }
141        if has_lower && has_upper {
142            break;
143        }
144    }
145    match (has_lower, has_upper) {
146        (true, true) => CaseStatus::Mixed,
147        (true, false) => CaseStatus::Lower,
148        (false, true) => CaseStatus::Upper,
149        (false, false) => CaseStatus::Lower, // empty / no letters; treat as lower
150    }
151}
152
153/// Result of a case check.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum CaseStatus {
156    /// All-lowercase or no letters.
157    Lower,
158    /// All-uppercase.
159    Upper,
160    /// Both lowercase and uppercase letters present (invalid).
161    Mixed,
162}
163
164/// BCH polymod constants for the regular checksum (BCH(93,80,8)).
165///
166/// Source: BIP 93 (codex32) reference implementation, `ms32_polymod` function.
167/// These five values are XORed into the running residue based on the top 5 bits
168/// of the residue at each step. The polymod operation uses a 65-bit residue
169/// (top 5 bits = current `b`, bottom 60 bits = masked state).
170///
171/// Verified against the canonical reference at
172/// <https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki>.
173pub const GEN_REGULAR: [u128; 5] = [
174    0x19dc500ce73fde210,
175    0x1bfae00def77fe529,
176    0x1fbd920fffe7bee52,
177    0x1739640bdeee3fdad,
178    0x07729a039cfc75f5a,
179];
180
181/// Constellation-internal initial residue that mk-codec's `ms32_polymod` and
182/// `ms32_long_polymod` seed before processing any input — shared byte-for-byte
183/// with md1 (`md-codec`'s `bch::POLYMOD_INIT`).
184///
185/// This value (`0x23181b3`) **IS** codex32/BIP-93's published `ms32_polymod`
186/// initial residue verbatim: the reference `ms32_polymod` seeds its accumulator
187/// with exactly this constant, and [`GEN_REGULAR`] / [`GEN_LONG`] are BIP-93's
188/// `ms32` / `ms32_long` generators term for term. (It is **not** bech32/BIP-173's
189/// init `1`; that init belongs to a different code. An earlier note here
190/// claiming this was "deliberately NOT codex32's init" and that "the BIP-93
191/// reference `ms32_polymod` starts from `1`, not `0x23181b3`" was wrong.)
192/// md1 and mk1 seed this init literally. `ms1` (`ms-codec`) uses the
193/// mathematically **equivalent** formulation — codex32's literal `1` init with
194/// an `hrp_expand("ms")` prepend — because `0x23181b3` is exactly the fold of
195/// `hrp_expand("ms")` from `1`; a raw constant-diff against `ms-codec`'s
196/// `POLYMOD_INIT = 0x1` is therefore NOT a discrepancy. Sharing this init with
197/// md1 is harmless: each of mk1's regular + long codes is self-contained (the
198/// same init seeds both checksum-create and verify), so the init's contribution
199/// cancels and a valid codeword's residue equals its per-HRP target at every
200/// length, for any fixed init. Domain separation is carried by the per-HRP
201/// target constants (`MK_REGULAR_CONST` / `MK_LONG_CONST`) + the HRP — never by
202/// this init. The reverted ms-codec
203/// v0.2.1 bug was a non-codex32 init *paired with* an empirically-miscalibrated
204/// target diverging from codex32 across lengths, not this value being
205/// intrinsically length-variant; see
206/// `mnemonic-secret/design/BUG_decode_with_correction_length_divergence.md`.
207pub const POLYMOD_INIT: u128 = 0x23181b3;
208
209/// Right-shift amount to extract the top 5 bits from a 65-bit regular-code residue.
210///
211/// Usage: `b = residue >> REGULAR_SHIFT` gives the 5-bit feedback selector
212/// for the polymod algorithm.
213pub const REGULAR_SHIFT: u32 = 60;
214
215/// Mask preserving the low 60 bits of a 65-bit regular-code residue.
216pub const REGULAR_MASK: u128 = 0x0fffffffffffffff;
217
218/// BCH polymod constants for the long checksum (BCH(108,93,8)).
219///
220/// Source: BIP 93 (codex32) reference implementation, `ms32_long_polymod` function.
221/// The long polymod uses a 75-bit residue (top 5 bits = `b`, bottom 70 bits = masked state).
222///
223/// Verified against the canonical reference at
224/// <https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki>.
225pub const GEN_LONG: [u128; 5] = [
226    0x3d59d273535ea62d897,
227    0x7a9becb6361c6c51507,
228    0x543f9b7e6c38d8a2a0e,
229    0x0c577eaeccf1990d13c,
230    0x1887f74f8dc71b10651,
231];
232
233/// Right-shift amount to extract the top 5 bits from a 75-bit long-code residue.
234///
235/// Usage: `b = residue >> LONG_SHIFT` gives the 5-bit feedback selector
236/// for the polymod algorithm.
237pub const LONG_SHIFT: u32 = 70;
238
239/// Mask preserving the low 70 bits of a 75-bit long-code residue.
240pub const LONG_MASK: u128 = 0x3fffffffffffffffff;
241
242/// One step of the BCH polymod algorithm from BIP 93.
243///
244/// Updates the running `residue` to incorporate the next 5-bit input `value`
245/// using the polynomial defined by `gen`, shift width `shift`, and mask `mask`.
246/// The same function is used for both the regular and long codes; pass
247/// `(GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK)` for the regular code and
248/// `(GEN_LONG, LONG_SHIFT, LONG_MASK)` for the long code.
249///
250/// Returns the updated residue after incorporating `value`. The top 5 bits of
251/// the returned residue feed the next iteration's `b` selector.
252///
253/// This is a direct port of BIP 93's `ms32_polymod` / `ms32_long_polymod` inner
254/// loop. See <https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki> .
255fn polymod_step(residue: u128, value: u128, r#gen: &[u128; 5], shift: u32, mask: u128) -> u128 {
256    let b = residue >> shift;
257    let mut new_residue = ((residue & mask) << 5) ^ value;
258    for (i, &g) in r#gen.iter().enumerate() {
259        if (b >> i) & 1 != 0 {
260            new_residue ^= g;
261        }
262    }
263    new_residue
264}
265
266/// BIP 173-style HRP-expansion: produces the 5-bit-symbol prelude that gets
267/// prepended to the data part before running the BCH polymod.
268///
269/// For each HRP character `c`, emits `c >> 5` (high 3 bits); then emits a
270/// single 0 separator; then emits each character's `c & 31` (low 5 bits).
271/// The result has length `2 * hrp.len() + 1` for ASCII HRPs.
272///
273/// For `hrp_expand("md")` this returns `[3, 3, 0, 13, 4]`.
274pub fn hrp_expand(hrp: &str) -> Vec<u8> {
275    let bytes = hrp.as_bytes();
276    let mut out = Vec::with_capacity(bytes.len() * 2 + 1);
277    for &c in bytes {
278        out.push(c >> 5);
279    }
280    out.push(0);
281    for &c in bytes {
282        out.push(c & 31);
283    }
284    out
285}
286
287/// Run polymod over a sequence of 5-bit values using the parameters for
288/// either the regular or long BCH code, starting from POLYMOD_INIT.
289///
290/// v0.3.1: promoted from `pub(in crate::string_layer)` to `pub` so
291/// downstream consumers (toolkit `repair` feature) can compute polymod
292/// residues against ms / md / mk target constants (all 3 share the
293/// BIP-93 BCH(93,80,8) generator). Test-helper-drift concern remains
294/// resolved by the sibling `bch_decode` module using THIS function
295/// directly rather than re-implementing.
296pub fn polymod_run(values: &[u8], r#gen: &[u128; 5], shift: u32, mask: u128) -> u128 {
297    let mut residue = POLYMOD_INIT;
298    for &v in values {
299        residue = polymod_step(residue, v as u128, r#gen, shift, mask);
300    }
301    residue
302}
303
304/// Compute the 13-character BCH checksum for the regular code over the
305/// HRP-expanded preamble plus the data part.
306///
307/// `data` is the sequence of 5-bit values for the data part (header + payload),
308/// not including the checksum. Returns the 13-element checksum array, ready
309/// to append to `data` to form the full data-part-plus-checksum.
310///
311/// The algorithm runs polymod over `hrp_expand(hrp) || data || [0; 13]`,
312/// then XORs the result with [`MK_REGULAR_CONST`] to extract the checksum.
313pub fn bch_create_checksum_regular(hrp: &str, data: &[u8]) -> [u8; 13] {
314    // Regular code: 13-symbol checksum (0..=12), pad/array/extraction all use 13.
315    let mut input = hrp_expand(hrp);
316    input.extend_from_slice(data);
317    input.extend(std::iter::repeat_n(0, 13));
318    let polymod = polymod_run(&input, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK) ^ MK_REGULAR_CONST;
319    let mut out = [0u8; 13];
320    for (i, slot) in out.iter_mut().enumerate() {
321        *slot = ((polymod >> (5 * (12 - i))) & 0x1F) as u8;
322    }
323    out
324}
325
326/// Verify a regular-code BCH checksum.
327///
328/// `data_with_checksum` is the full data part including the trailing 13
329/// checksum characters. Returns `true` iff the polymod over
330/// `hrp_expand(hrp) || data_with_checksum` equals [`MK_REGULAR_CONST`].
331pub fn bch_verify_regular(hrp: &str, data_with_checksum: &[u8]) -> bool {
332    if data_with_checksum.len() < 13 {
333        return false;
334    }
335    let mut input = hrp_expand(hrp);
336    input.extend_from_slice(data_with_checksum);
337    polymod_run(&input, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK) == MK_REGULAR_CONST
338}
339
340/// Compute the 15-character BCH checksum for the long code.
341///
342/// Same algorithm as [`bch_create_checksum_regular`] but uses the long-code
343/// polymod parameters (`GEN_LONG`, `LONG_SHIFT`, `LONG_MASK`) and target
344/// constant ([`MK_LONG_CONST`]). Produces a 15-element checksum array.
345pub fn bch_create_checksum_long(hrp: &str, data: &[u8]) -> [u8; 15] {
346    // Long code: 15-symbol checksum (0..=14), pad/array/extraction all use 15.
347    let mut input = hrp_expand(hrp);
348    input.extend_from_slice(data);
349    input.extend(std::iter::repeat_n(0, 15));
350    let polymod = polymod_run(&input, &GEN_LONG, LONG_SHIFT, LONG_MASK) ^ MK_LONG_CONST;
351    let mut out = [0u8; 15];
352    for (i, slot) in out.iter_mut().enumerate() {
353        *slot = ((polymod >> (5 * (14 - i))) & 0x1F) as u8;
354    }
355    out
356}
357
358/// Verify a long-code BCH checksum.
359///
360/// Same algorithm as [`bch_verify_regular`] with long-code parameters.
361/// Returns false if `data_with_checksum` is shorter than 15 symbols.
362pub fn bch_verify_long(hrp: &str, data_with_checksum: &[u8]) -> bool {
363    if data_with_checksum.len() < 15 {
364        return false;
365    }
366    let mut input = hrp_expand(hrp);
367    input.extend_from_slice(data_with_checksum);
368    polymod_run(&input, &GEN_LONG, LONG_SHIFT, LONG_MASK) == MK_LONG_CONST
369}
370
371/// Result of a successful BCH decode + correct attempt.
372///
373/// Returned by [`bch_correct_regular`] / [`bch_correct_long`] when correction
374/// succeeds. `corrections_applied == 0` means the input was already valid;
375/// `> 0` means substitutions were applied at the indicated positions.
376///
377/// Marked `#[non_exhaustive]` to allow future fields (e.g., confidence
378/// score, syndrome metadata) without breaking downstream struct-literal
379/// construction. Construct via the [`bch_correct_regular`] /
380/// [`bch_correct_long`] APIs.
381#[non_exhaustive]
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct CorrectionResult {
384    /// The corrected `data_with_checksum` slice (input may have been modified).
385    pub data: Vec<u8>,
386    /// Number of substitutions applied (0 = clean input).
387    pub corrections_applied: usize,
388    /// Indices into `data` of the substituted positions.
389    pub corrected_positions: Vec<usize>,
390}
391
392/// Attempt to correct a regular-code BCH-checksummed string with up to four
393/// substitutions, the full t = 4 capacity of the BCH(93, 80, 8) code.
394///
395/// Implements the standard syndrome-based BCH decoder pipeline: syndrome
396/// computation in `GF(1024) = GF(32²)`, Berlekamp–Massey for the
397/// error-locator polynomial, Chien search for error positions, Forney's
398/// algorithm for error magnitudes. After applying the proposed corrections,
399/// the result is re-verified via [`bch_verify_regular`]; the decoder rejects
400/// any output that does not produce a valid codeword (defensive guard
401/// against pathological 5+-error inputs whose syndromes happen to factor as
402/// a degree-≤ 4 locator).
403///
404/// Returns `Ok(CorrectionResult)` if the input is clean or up to four
405/// substitutions repair it. Returns `Err(Error::BchUncorrectable)` otherwise.
406///
407/// # Algorithm details
408///
409/// See the private `bch_decode` submodule for the algorithm and the
410/// `GF(1024)` field representation.
411pub fn bch_correct_regular(
412    hrp: &str,
413    data_with_checksum: &[u8],
414) -> Result<CorrectionResult, crate::Error> {
415    if bch_verify_regular(hrp, data_with_checksum) {
416        return Ok(CorrectionResult {
417            data: data_with_checksum.to_vec(),
418            corrections_applied: 0,
419            corrected_positions: vec![],
420        });
421    }
422    // Compute polymod over hrp_expand(hrp) || data_with_checksum, XOR with
423    // the MD target constant. The result is congruent to the error
424    // polynomial E(x) modulo g_regular(x).
425    let mut input = hrp_expand(hrp);
426    input.extend_from_slice(data_with_checksum);
427    let residue = polymod_run(&input, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK) ^ MK_REGULAR_CONST;
428
429    if let Some((positions, magnitudes)) =
430        bch_decode::decode_regular_errors(residue, data_with_checksum.len())
431    {
432        if positions.is_empty() {
433            // Should be unreachable (caller already verified); guard anyway.
434            return Ok(CorrectionResult {
435                data: data_with_checksum.to_vec(),
436                corrections_applied: 0,
437                corrected_positions: vec![],
438            });
439        }
440        let mut corrected = data_with_checksum.to_vec();
441        for (&p, &m) in positions.iter().zip(&magnitudes) {
442            if p >= corrected.len() {
443                return Err(crate::Error::BchUncorrectable(format!(
444                    "decoder reported error position {p} outside data ({} symbols)",
445                    corrected.len()
446                )));
447            }
448            corrected[p] ^= m;
449        }
450        // Defensive: re-verify. Catches the 5+-error edge case.
451        if bch_verify_regular(hrp, &corrected) {
452            return Ok(CorrectionResult {
453                corrections_applied: positions.len(),
454                corrected_positions: positions,
455                data: corrected,
456            });
457        }
458    }
459    Err(crate::Error::BchUncorrectable(
460        "regular code: more than 4 substitutions or pathological pattern".into(),
461    ))
462}
463
464/// Long-code analog of [`bch_correct_regular`].
465///
466/// Implements the same BM/Chien/Forney pipeline against the long-code
467/// generator polynomial, reaching the full t = 4 capacity of
468/// `BCH(108, 93, 8)`.
469pub fn bch_correct_long(
470    hrp: &str,
471    data_with_checksum: &[u8],
472) -> Result<CorrectionResult, crate::Error> {
473    if bch_verify_long(hrp, data_with_checksum) {
474        return Ok(CorrectionResult {
475            data: data_with_checksum.to_vec(),
476            corrections_applied: 0,
477            corrected_positions: vec![],
478        });
479    }
480    let mut input = hrp_expand(hrp);
481    input.extend_from_slice(data_with_checksum);
482    let residue = polymod_run(&input, &GEN_LONG, LONG_SHIFT, LONG_MASK) ^ MK_LONG_CONST;
483
484    if let Some((positions, magnitudes)) =
485        bch_decode::decode_long_errors(residue, data_with_checksum.len())
486    {
487        if positions.is_empty() {
488            return Ok(CorrectionResult {
489                data: data_with_checksum.to_vec(),
490                corrections_applied: 0,
491                corrected_positions: vec![],
492            });
493        }
494        let mut corrected = data_with_checksum.to_vec();
495        for (&p, &m) in positions.iter().zip(&magnitudes) {
496            if p >= corrected.len() {
497                return Err(crate::Error::BchUncorrectable(format!(
498                    "decoder reported error position {p} outside data ({} symbols)",
499                    corrected.len()
500                )));
501            }
502            corrected[p] ^= m;
503        }
504        if bch_verify_long(hrp, &corrected) {
505            return Ok(CorrectionResult {
506                corrections_applied: positions.len(),
507                corrected_positions: positions,
508                data: corrected,
509            });
510        }
511    }
512    Err(crate::Error::BchUncorrectable(
513        "long code: more than 4 substitutions or pathological pattern".into(),
514    ))
515}
516
517/// Encode a 5-bit-symbol data stream as a complete mk1 string.
518///
519/// The data stream is the concatenation `header_symbols || bytes_to_5bit(payload_bytes)`
520/// where `header_symbols` is the 2-symbol single-string header or the
521/// 8-symbol chunked header (closure Q-5). The BCH code variant (regular or
522/// long) is auto-selected from the resulting data-part length per BIP 93:
523/// regular for ≤93-symbol data parts, long for 96–108-symbol data parts.
524/// Lengths in the reserved-invalid 94–95 gap or outside the BIP 93 valid
525/// range return [`Error::InvalidStringLength`].
526///
527/// Per the v0.1 emit policy described in `design/IMPLEMENTATION_PLAN_mk_v0_1.md`
528/// §5.4, callers control fragment sizing so that each chunked fragment lands
529/// within long-code territory. Single-string mk1 may pick regular or long
530/// based on bytecode size.
531///
532/// Returns the full string starting with [`crate::consts::HRP`] and the
533/// BIP 173 separator (`"mk1"`).
534pub fn encode_5bit_to_string(data_5bit: &[u8]) -> Result<String, crate::Error> {
535    use crate::Error;
536
537    // Auto-determine code from the eventual data-part length (data_5bit + checksum).
538    let regular_total = data_5bit.len() + 13;
539    let long_total = data_5bit.len() + 15;
540    let code = match (
541        bch_code_for_length(regular_total),
542        bch_code_for_length(long_total),
543    ) {
544        (Some(BchCode::Regular), _) => BchCode::Regular,
545        (_, Some(BchCode::Long)) => BchCode::Long,
546        // Neither code variant accepts this data-part length: too short, in
547        // the 94–95 reserved-invalid gap, or too long for v0.1.
548        _ => {
549            // Pick the closest length to report — long_total is always larger,
550            // so report that as the "actual length you tried to produce".
551            return Err(Error::InvalidStringLength(long_total));
552        }
553    };
554
555    let checksum: Vec<u8> = match code {
556        BchCode::Regular => bch_create_checksum_regular(HRP, data_5bit).to_vec(),
557        BchCode::Long => bch_create_checksum_long(HRP, data_5bit).to_vec(),
558    };
559
560    let mut full = String::with_capacity(HRP.len() + 1 + data_5bit.len() + checksum.len());
561    full.push_str(HRP);
562    full.push(SEPARATOR);
563    for &v in data_5bit {
564        full.push(ALPHABET[v as usize] as char);
565    }
566    for v in checksum {
567        full.push(ALPHABET[v as usize] as char);
568    }
569    Ok(full)
570}
571
572/// Result of a successful mk1 string decode at the BCH layer.
573///
574/// Use [`Self::data`] to access the data part as 5-bit values (header
575/// symbols + payload, checksum stripped); the string-layer reassembler
576/// in `crate::string_layer` splits header symbols off and feeds the
577/// remaining payload through [`five_bit_to_bytes`] to recover the original
578/// fragment bytes.
579///
580/// The full post-correction 5-bit symbol sequence (data **plus** the trailing
581/// 13- or 15-char checksum) is retained internally as [`Self::data_with_checksum`]
582/// and can be queried by [`Self::corrected_char_at`] for any position in
583/// the data part — including positions that fall inside the checksum region.
584/// The decoder-report layer uses this to surface the real corrected
585/// character when BCH ECC repairs a substitution inside the checksum
586/// (parallels md-codec's `Correction.corrected` field).
587#[non_exhaustive]
588#[derive(Debug, Clone, PartialEq, Eq)]
589pub struct DecodedString {
590    /// Detected BCH code variant.
591    pub code: BchCode,
592    /// Number of substitution errors corrected (0 = clean input, 1 = recovered).
593    pub corrections_applied: usize,
594    /// Indices into the data-part (chars after `"md1"`) of any corrected positions.
595    pub corrected_positions: Vec<usize>,
596    /// Full post-correction 5-bit symbol sequence (data part + checksum), in
597    /// the same coordinate system as [`Self::corrected_positions`].
598    ///
599    /// Length is `data().len() + 13` (regular code) or `data().len() + 15`
600    /// (long code). Indices `0..data().len()` mirror [`Self::data`] symbol-for-symbol;
601    /// indices `data().len()..` are the corrected checksum symbols. Use
602    /// [`Self::corrected_char_at`] for the human-readable bech32 character at
603    /// any position.
604    pub data_with_checksum: Vec<u8>,
605}
606
607impl DecodedString {
608    /// Data part as 5-bit values, with the trailing checksum stripped.
609    ///
610    /// Returns a slice into [`Self::data_with_checksum`] — the data part is
611    /// `data_with_checksum[..len - checksum_len]`, where `checksum_len` is 13
612    /// for [`BchCode::Regular`] and 15 for [`BchCode::Long`].
613    pub fn data(&self) -> &[u8] {
614        let checksum_len = match self.code {
615            BchCode::Regular => 13,
616            BchCode::Long => 15,
617        };
618        &self.data_with_checksum[..self.data_with_checksum.len() - checksum_len]
619    }
620
621    /// Look up the corrected bech32 character at the given position in the
622    /// data part (chars after the `"md1"` HRP+separator).
623    ///
624    /// `char_position` is 0-indexed. Positions `0..data().len()` are in the
625    /// data region; positions `data().len()..data().len() + checksum_len` are
626    /// inside the BCH checksum (13 chars for [`BchCode::Regular`], 15 for
627    /// [`BchCode::Long`]). All positions return the post-correction
628    /// character — i.e., what the symbol *should* be after BCH repair, which
629    /// is exactly what [`Correction.corrected`][crate::Correction::corrected]
630    /// is documented to report.
631    ///
632    /// # Panics
633    ///
634    /// Panics if `char_position >= data_with_checksum.len()`. Callers are
635    /// responsible for clamping the position to a valid range; in the decode
636    /// pipeline this is guaranteed by the BCH layer (it never reports a
637    /// `corrected_position` outside `data_with_checksum`). Note that
638    /// `data_with_checksum` includes the checksum region; "outside the data
639    /// part" elsewhere in this crate excludes the checksum and is a tighter
640    /// bound than what this method requires.
641    pub fn corrected_char_at(&self, char_position: usize) -> char {
642        let v = self.data_with_checksum[char_position];
643        ALPHABET[v as usize] as char
644    }
645}
646
647/// Decode an mk1 string, validating HRP, case, length, and checksum.
648///
649/// Performs full BCH error correction up to four substitutions
650/// (`t = 4` capacity of the BCH(93, 80, 8) regular code and the
651/// BCH(108, 93, 8) long code), via syndrome-based Berlekamp–Massey +
652/// Forney decoding (implemented in the sibling `bch_decode` module).
653///
654/// Errors:
655/// - [`Error::MixedCase`] if the string mixes upper and lower case.
656/// - [`Error::InvalidHrp`] if the HRP is missing or not [`crate::consts::HRP`].
657/// - [`Error::InvalidStringLength`] if the data-part length isn't a valid mk1 length.
658/// - [`Error::InvalidChar`] if the data part contains a non-bech32 character.
659/// - [`Error::BchUncorrectable`] if the checksum can't be repaired within
660///   the BCH `t = 4` correction radius.
661///
662/// [`Error::MixedCase`]: crate::Error::MixedCase
663/// [`Error::InvalidHrp`]: crate::Error::InvalidHrp
664/// [`Error::InvalidStringLength`]: crate::Error::InvalidStringLength
665/// [`Error::InvalidChar`]: crate::Error::InvalidChar
666/// [`Error::BchUncorrectable`]: crate::Error::BchUncorrectable
667pub fn decode_string(s: &str) -> Result<DecodedString, crate::Error> {
668    use crate::Error;
669
670    if matches!(case_check(s), CaseStatus::Mixed) {
671        return Err(Error::MixedCase);
672    }
673    let s_lower = s.to_lowercase();
674
675    let sep_pos = s_lower
676        .rfind(SEPARATOR)
677        .ok_or_else(|| Error::InvalidHrp(s_lower.clone()))?;
678    let (hrp, rest) = s_lower.split_at(sep_pos);
679    let data_part = &rest[1..]; // skip the '1' separator
680
681    if hrp != HRP {
682        return Err(Error::InvalidHrp(hrp.to_string()));
683    }
684
685    let code =
686        bch_code_for_length(data_part.len()).ok_or(Error::InvalidStringLength(data_part.len()))?;
687
688    let mut values: Vec<u8> = Vec::with_capacity(data_part.len());
689    for (i, c) in data_part.chars().enumerate() {
690        if !c.is_ascii() {
691            return Err(Error::InvalidChar { ch: c, position: i });
692        }
693        let v = ALPHABET_INV[c as usize];
694        if v == 0xFF {
695            return Err(Error::InvalidChar { ch: c, position: i });
696        }
697        values.push(v);
698    }
699
700    let correction = match code {
701        BchCode::Regular => bch_correct_regular(hrp, &values),
702        BchCode::Long => bch_correct_long(hrp, &values),
703    };
704    let result = correction?;
705
706    Ok(DecodedString {
707        code,
708        corrections_applied: result.corrections_applied,
709        corrected_positions: result.corrected_positions,
710        data_with_checksum: result.data,
711    })
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn bch_code_equality() {
720        assert_eq!(BchCode::Regular, BchCode::Regular);
721        assert_ne!(BchCode::Regular, BchCode::Long);
722    }
723
724    #[test]
725    fn bch_code_can_be_hashed() {
726        use std::collections::HashSet;
727        let mut set = HashSet::new();
728        set.insert(BchCode::Regular);
729        set.insert(BchCode::Long);
730        set.insert(BchCode::Regular);
731        assert_eq!(set.len(), 2);
732    }
733
734    #[test]
735    fn alphabet_is_32_unique_chars() {
736        let mut seen = std::collections::HashSet::new();
737        for &c in ALPHABET {
738            assert!(seen.insert(c), "duplicate char in alphabet: {}", c as char);
739        }
740        assert_eq!(seen.len(), 32);
741    }
742
743    #[test]
744    fn bytes_to_5bit_round_trip_zero() {
745        let bytes = vec![0x00];
746        let fives = bytes_to_5bit(&bytes);
747        assert_eq!(fives, vec![0, 0]);
748        let back = five_bit_to_bytes(&fives).unwrap();
749        assert_eq!(back, bytes);
750    }
751
752    #[test]
753    fn bytes_to_5bit_round_trip_known_value() {
754        // 0xFF = binary 11111111. Splits as 11111 (=31) and 111 (padded with 00 to 11100=28).
755        let bytes = vec![0xFF];
756        let fives = bytes_to_5bit(&bytes);
757        assert_eq!(fives, vec![31, 28]);
758    }
759
760    #[test]
761    fn bytes_to_5bit_round_trip_multibyte() {
762        // 3 bytes = 24 bits → 5 five-bit groups (25 bits, 1 pad bit).
763        let bytes = vec![0xDE, 0xAD, 0xBE];
764        let back = five_bit_to_bytes(&bytes_to_5bit(&bytes)).unwrap();
765        assert_eq!(back, bytes);
766    }
767
768    #[test]
769    fn five_bit_to_bytes_rejects_nonzero_padding() {
770        // Two 5-bit values = 10 bits, of which 8 form a byte and 2 are padding.
771        // If padding bits are nonzero, decode must fail.
772        // 31 = 11111, 1 = 00001. Last 2 bits (= 01) are nonzero padding.
773        assert!(five_bit_to_bytes(&[31, 1]).is_none());
774    }
775
776    #[test]
777    fn five_bit_to_bytes_rejects_value_out_of_range() {
778        assert!(five_bit_to_bytes(&[32]).is_none());
779    }
780
781    #[test]
782    fn bch_code_for_length_regular() {
783        assert_eq!(bch_code_for_length(14), Some(BchCode::Regular));
784        assert_eq!(bch_code_for_length(93), Some(BchCode::Regular));
785    }
786
787    #[test]
788    fn bch_code_for_length_long() {
789        assert_eq!(bch_code_for_length(96), Some(BchCode::Long));
790        assert_eq!(bch_code_for_length(108), Some(BchCode::Long));
791    }
792
793    #[test]
794    fn bch_code_for_length_rejects_94_and_95() {
795        assert_eq!(bch_code_for_length(94), None);
796        assert_eq!(bch_code_for_length(95), None);
797    }
798
799    #[test]
800    fn bch_code_for_length_rejects_extremes() {
801        assert_eq!(bch_code_for_length(0), None);
802        assert_eq!(bch_code_for_length(13), None);
803        assert_eq!(bch_code_for_length(109), None);
804        assert_eq!(bch_code_for_length(1000), None);
805    }
806
807    #[test]
808    fn case_check_lowercase() {
809        assert_eq!(case_check("md1qq"), CaseStatus::Lower);
810    }
811
812    #[test]
813    fn case_check_uppercase() {
814        assert_eq!(case_check("MD1QQ"), CaseStatus::Upper);
815    }
816
817    #[test]
818    fn case_check_mixed() {
819        assert_eq!(case_check("mD1qq"), CaseStatus::Mixed);
820    }
821
822    #[test]
823    fn case_check_empty_string_is_lower() {
824        assert_eq!(case_check(""), CaseStatus::Lower);
825    }
826
827    #[test]
828    fn case_check_digits_only_is_lower() {
829        // Digits have no case; result must be Lower (BIP 173: no-letter strings are lower).
830        assert_eq!(case_check("1234"), CaseStatus::Lower);
831    }
832
833    #[test]
834    fn gen_regular_has_5_entries() {
835        assert_eq!(GEN_REGULAR.len(), 5);
836    }
837
838    #[test]
839    fn gen_long_has_5_entries() {
840        assert_eq!(GEN_LONG.len(), 5);
841    }
842
843    #[test]
844    fn gen_regular_matches_bip93_canonical_values() {
845        // Cross-checked against https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki
846        // ms32_polymod GEN array. If this fails, the constants drifted from the BIP.
847        assert_eq!(GEN_REGULAR[0], 0x19dc500ce73fde210);
848        assert_eq!(GEN_REGULAR[1], 0x1bfae00def77fe529);
849        assert_eq!(GEN_REGULAR[2], 0x1fbd920fffe7bee52);
850        assert_eq!(GEN_REGULAR[3], 0x1739640bdeee3fdad);
851        assert_eq!(GEN_REGULAR[4], 0x07729a039cfc75f5a);
852    }
853
854    #[test]
855    fn gen_long_matches_bip93_canonical_values() {
856        // Cross-checked against https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki
857        // ms32_long_polymod GEN array.
858        assert_eq!(GEN_LONG[0], 0x3d59d273535ea62d897);
859        assert_eq!(GEN_LONG[1], 0x7a9becb6361c6c51507);
860        assert_eq!(GEN_LONG[2], 0x543f9b7e6c38d8a2a0e);
861        assert_eq!(GEN_LONG[3], 0x0c577eaeccf1990d13c);
862        assert_eq!(GEN_LONG[4], 0x1887f74f8dc71b10651);
863    }
864
865    #[test]
866    fn polymod_init_matches_bip93() {
867        // POLYMOD_INIT is unchanged from BIP 93; the GEN_REGULAR / GEN_LONG
868        // constants have their own value-equality tests.
869        assert_eq!(POLYMOD_INIT, 0x23181b3);
870    }
871
872    // (NUMS-derivation reproducer for `MK_REGULAR_CONST` / `MK_LONG_CONST`
873    // lives in `crate::consts::tests::nums_constants_reproduce_from_domain`,
874    // which uses the mk1-specific domain `b"shibbolethnumskey"`. Duplicating
875    // it here would risk drift if either side were updated in isolation.)
876
877    #[test]
878    fn polymod_masks_are_consistent_with_shifts() {
879        // The mask must be (1 << shift) - 1 so that masking preserves bits below
880        // the shift boundary, exactly matching the BIP 93 algorithm.
881        assert_eq!(REGULAR_MASK, (1u128 << REGULAR_SHIFT) - 1);
882        assert_eq!(LONG_MASK, (1u128 << LONG_SHIFT) - 1);
883        assert_eq!(REGULAR_SHIFT, 60);
884        assert_eq!(LONG_SHIFT, 70);
885    }
886
887    #[test]
888    fn polymod_step_zero_residue_zero_value() {
889        // Both residue and value zero, no GEN XORs since b = 0.
890        assert_eq!(
891            polymod_step(0, 0, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK),
892            0
893        );
894    }
895
896    #[test]
897    fn polymod_step_value_only_xor_when_residue_zero() {
898        // Residue 0, value 7 → result is 7 (XORed into the shifted-zero residue).
899        assert_eq!(
900            polymod_step(0, 7, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK),
901            7
902        );
903    }
904
905    #[test]
906    fn polymod_step_isolates_each_gen_entry() {
907        // Setting just bit `shift+i` in the residue → b = 1<<i → only GEN[i] is XORed.
908        for i in 0..5 {
909            let r = 1u128 << (REGULAR_SHIFT + i);
910            assert_eq!(
911                polymod_step(r, 0, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK),
912                GEN_REGULAR[i as usize],
913                "bit {} of b should isolate GEN_REGULAR[{}]",
914                i,
915                i
916            );
917        }
918    }
919
920    #[test]
921    fn polymod_step_xors_multiple_gens_when_multiple_b_bits_set() {
922        // b = 0b00011 → XOR GEN[0] and GEN[1].
923        let r = 0b00011u128 << REGULAR_SHIFT;
924        assert_eq!(
925            polymod_step(r, 0, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK),
926            GEN_REGULAR[0] ^ GEN_REGULAR[1]
927        );
928        // b = 0b11111 → XOR all 5.
929        let r = 0b11111u128 << REGULAR_SHIFT;
930        let expected =
931            GEN_REGULAR[0] ^ GEN_REGULAR[1] ^ GEN_REGULAR[2] ^ GEN_REGULAR[3] ^ GEN_REGULAR[4];
932        assert_eq!(
933            polymod_step(r, 0, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK),
934            expected
935        );
936    }
937
938    #[test]
939    fn polymod_step_works_for_long_code() {
940        // Same parameterization works for the long code (shift=70, mask=LONG_MASK).
941        let r = 1u128 << LONG_SHIFT;
942        assert_eq!(
943            polymod_step(r, 0, &GEN_LONG, LONG_SHIFT, LONG_MASK),
944            GEN_LONG[0]
945        );
946        // b = 0b11111 → XOR all 5 long GENs.
947        let r = 0b11111u128 << LONG_SHIFT;
948        let expected = GEN_LONG[0] ^ GEN_LONG[1] ^ GEN_LONG[2] ^ GEN_LONG[3] ^ GEN_LONG[4];
949        assert_eq!(
950            polymod_step(r, 0, &GEN_LONG, LONG_SHIFT, LONG_MASK),
951            expected
952        );
953    }
954
955    #[test]
956    fn polymod_step_init_residue_first_iteration() {
957        // POLYMOD_INIT < 2^60 so b = 0 in the first iteration; only the shift+xor happens.
958        // Verify: polymod_step(POLYMOD_INIT, 0) = POLYMOD_INIT << 5.
959        assert_eq!(
960            polymod_step(POLYMOD_INIT, 0, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK),
961            POLYMOD_INIT << 5
962        );
963        // And with value=v: polymod_step(POLYMOD_INIT, v) = (POLYMOD_INIT << 5) ^ v.
964        assert_eq!(
965            polymod_step(POLYMOD_INIT, 31, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK),
966            (POLYMOD_INIT << 5) ^ 31
967        );
968    }
969
970    #[test]
971    fn polymod_step_value_and_gen_xor_combined() {
972        // Both effects active: b = 1 (bit 0 of b set) AND value = 5.
973        // Expected: ((residue & mask) << 5) ^ value ^ GEN[0]
974        //         = (0 << 5) ^ 5 ^ GEN[0]
975        //         = GEN_REGULAR[0] ^ 5
976        let r = 1u128 << REGULAR_SHIFT;
977        assert_eq!(
978            polymod_step(r, 5, &GEN_REGULAR, REGULAR_SHIFT, REGULAR_MASK),
979            GEN_REGULAR[0] ^ 5
980        );
981    }
982
983    #[test]
984    fn hrp_expand_mk_matches_spec() {
985        // BIP 173 hrp_expand for the MK HRP. Each ASCII byte contributes
986        // its high 3 bits then (after the [0] separator) its low 5 bits.
987        // 'm' = 0x6D → high 3 bits = 3, low 5 bits = 13.
988        // 'k' = 0x6B → high 3 bits = 3, low 5 bits = 11.
989        // Result: [3, 3, 0, 13, 11]. Documented in the BIP draft §"Checksum".
990        assert_eq!(hrp_expand(crate::consts::HRP), vec![3, 3, 0, 13, 11]);
991    }
992
993    #[test]
994    fn hrp_expand_empty_returns_just_separator() {
995        // Edge case: empty HRP yields just the [0] separator.
996        assert_eq!(hrp_expand(""), vec![0]);
997    }
998
999    #[test]
1000    fn bch_round_trip_regular() {
1001        // Encode then verify a small data part. The verify call sees the
1002        // full data + checksum, so polymod returns MK_REGULAR_CONST exactly.
1003        let hrp = "mk";
1004        let data: Vec<u8> = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
1005        let checksum = bch_create_checksum_regular(hrp, &data);
1006        assert_eq!(checksum.len(), 13);
1007
1008        let mut full = data.clone();
1009        full.extend_from_slice(&checksum);
1010        assert!(bch_verify_regular(hrp, &full));
1011    }
1012
1013    #[test]
1014    fn bch_verify_rejects_single_char_tampering_regular() {
1015        // Flipping one bit in one symbol breaks verification.
1016        // (Spot check; BCH detects all single-symbol errors by construction.)
1017        let hrp = "mk";
1018        let data: Vec<u8> = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
1019        let checksum = bch_create_checksum_regular(hrp, &data);
1020        let mut full = data.clone();
1021        full.extend_from_slice(&checksum);
1022        full[5] ^= 0x01;
1023        assert!(!bch_verify_regular(hrp, &full));
1024    }
1025
1026    #[test]
1027    fn bch_verify_rejects_too_short_input_regular() {
1028        // Less than 13 symbols cannot hold a checksum.
1029        assert!(!bch_verify_regular("mk", &[0, 1, 2]));
1030        assert!(!bch_verify_regular("mk", &[]));
1031    }
1032
1033    // (mk1-specific pinned-checksum vectors are deferred to Phase 6 vector
1034    // corpus generation, which writes both regular- and long-code conformance
1035    // points to disk under `crates/mk-codec/src/test_vectors/v0.1.json`.
1036    // Forking md-codec's pinned vectors verbatim would record the wrong
1037    // values: mk1's HRP and target constants both differ.)
1038
1039    #[test]
1040    fn bch_zero_data_does_not_self_validate_regular() {
1041        // The all-zeros data + all-zeros checksum must NOT validate, because
1042        // MK_REGULAR_CONST was chosen NUMS-style to avoid this trivial case.
1043        // Data length 8 is arbitrary; any non-empty zero-fill exhibits the same
1044        // negative result. 8 echoes the regular-code known-vector data length.
1045        let mut zero = vec![0u8; 8];
1046        zero.extend(std::iter::repeat_n(0, 13));
1047        assert!(!bch_verify_regular("mk", &zero));
1048    }
1049
1050    #[test]
1051    fn bch_round_trip_empty_data_regular() {
1052        // Empty data part is a degenerate but valid input: the checksum
1053        // covers only the HRP preamble. encode → verify must round-trip.
1054        let checksum = bch_create_checksum_regular("mk", &[]);
1055        assert!(bch_verify_regular("mk", &checksum));
1056    }
1057
1058    #[test]
1059    fn bch_round_trip_long() {
1060        let hrp = "mk";
1061        let data: Vec<u8> = (0..16).collect();
1062        let checksum = bch_create_checksum_long(hrp, &data);
1063        assert_eq!(checksum.len(), 15);
1064        let mut full = data.clone();
1065        full.extend_from_slice(&checksum);
1066        assert!(bch_verify_long(hrp, &full));
1067    }
1068
1069    #[test]
1070    fn bch_verify_rejects_single_char_tampering_long() {
1071        // Flipping one bit in one symbol breaks verification.
1072        // (Spot check; BCH detects all single-symbol errors by construction.)
1073        let hrp = "mk";
1074        let data: Vec<u8> = (0..16).collect();
1075        let checksum = bch_create_checksum_long(hrp, &data);
1076        let mut full = data.clone();
1077        full.extend_from_slice(&checksum);
1078        full[7] ^= 0x01;
1079        assert!(!bch_verify_long(hrp, &full));
1080    }
1081
1082    #[test]
1083    fn bch_verify_rejects_too_short_input_long() {
1084        // Less than 15 symbols cannot hold a long-code checksum.
1085        assert!(!bch_verify_long("mk", &[0; 14]));
1086        assert!(!bch_verify_long("mk", &[]));
1087    }
1088
1089    #[test]
1090    fn bch_zero_data_does_not_self_validate_long() {
1091        // All-zeros must not validate, by NUMS construction of MK_LONG_CONST.
1092        // Data length 16 is arbitrary; any non-empty zero-fill exhibits the same
1093        // negative result. 16 echoes the long-code known-vector data length.
1094        let mut zero = vec![0u8; 16];
1095        zero.extend(std::iter::repeat_n(0, 15));
1096        assert!(!bch_verify_long("mk", &zero));
1097    }
1098
1099    #[test]
1100    fn bch_round_trip_empty_data_long() {
1101        // Degenerate but valid: checksum covers only the HRP preamble.
1102        let checksum = bch_create_checksum_long("mk", &[]);
1103        assert!(bch_verify_long("mk", &checksum));
1104    }
1105
1106    #[test]
1107    fn bch_correct_regular_clean_input() {
1108        // Clean input → 0 corrections, identity result.
1109        let hrp = "mk";
1110        let data: Vec<u8> = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
1111        let checksum = bch_create_checksum_regular(hrp, &data);
1112        let mut full = data.clone();
1113        full.extend_from_slice(&checksum);
1114        let r = bch_correct_regular(hrp, &full).unwrap();
1115        assert_eq!(r.corrections_applied, 0);
1116        assert!(r.corrected_positions.is_empty());
1117        assert_eq!(r.data, full);
1118    }
1119
1120    #[test]
1121    fn bch_correct_regular_one_error() {
1122        // Single-symbol corruption is recoverable.
1123        let hrp = "mk";
1124        let data: Vec<u8> = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
1125        let checksum = bch_create_checksum_regular(hrp, &data);
1126        let mut full = data.clone();
1127        full.extend_from_slice(&checksum);
1128        let original = full.clone();
1129        full[3] = (full[3] + 1) & 0x1F;
1130        let r = bch_correct_regular(hrp, &full).unwrap();
1131        assert_eq!(r.corrections_applied, 1);
1132        assert_eq!(r.corrected_positions, vec![3]);
1133        assert_eq!(r.data, original);
1134    }
1135
1136    #[test]
1137    fn bch_correct_regular_two_errors_recovered_v0_2() {
1138        // v0.2 BM/Forney decoder reaches the BCH(93,80,8) full t = 4
1139        // capacity. A 2-error pattern is now recoverable. This test was
1140        // `..._uncorrectable_v0_1` in v0.1; flipped sign in v0.2.
1141        let hrp = "mk";
1142        let data: Vec<u8> = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
1143        let checksum = bch_create_checksum_regular(hrp, &data);
1144        let mut full = data.clone();
1145        full.extend_from_slice(&checksum);
1146        let original = full.clone();
1147        full[3] = (full[3] + 1) & 0x1F;
1148        full[7] = (full[7] + 1) & 0x1F;
1149        let r = bch_correct_regular(hrp, &full).unwrap();
1150        assert_eq!(r.corrections_applied, 2);
1151        assert!(r.corrected_positions.contains(&3));
1152        assert!(r.corrected_positions.contains(&7));
1153        assert_eq!(r.data, original);
1154    }
1155
1156    #[test]
1157    fn bch_correct_long_clean_input() {
1158        let hrp = "mk";
1159        let data: Vec<u8> = (0..16).collect();
1160        let checksum = bch_create_checksum_long(hrp, &data);
1161        let mut full = data.clone();
1162        full.extend_from_slice(&checksum);
1163        let r = bch_correct_long(hrp, &full).unwrap();
1164        assert_eq!(r.corrections_applied, 0);
1165    }
1166
1167    #[test]
1168    fn bch_correct_long_one_error() {
1169        let hrp = "mk";
1170        let data: Vec<u8> = (0..16).collect();
1171        let checksum = bch_create_checksum_long(hrp, &data);
1172        let mut full = data.clone();
1173        full.extend_from_slice(&checksum);
1174        let original = full.clone();
1175        full[5] = (full[5] + 1) & 0x1F;
1176        let r = bch_correct_long(hrp, &full).unwrap();
1177        assert_eq!(r.corrections_applied, 1);
1178        assert_eq!(r.corrected_positions, vec![5]);
1179        assert_eq!(r.data, original);
1180    }
1181
1182    #[test]
1183    fn bch_correct_returns_correction_result_with_position() {
1184        // Verify the API contract: a successful 1-error correction reports
1185        // exactly the position that was changed.
1186        let hrp = "mk";
1187        let data: Vec<u8> = vec![0, 1, 2, 3, 4, 5, 6, 7];
1188        let checksum = bch_create_checksum_regular(hrp, &data);
1189        let mut full = data.clone();
1190        full.extend_from_slice(&checksum);
1191        // Damage the second checksum byte (position 9 from start).
1192        full[9] = (full[9] + 7) & 0x1F;
1193        let r = bch_correct_regular(hrp, &full).unwrap();
1194        assert_eq!(r.corrected_positions, vec![9]);
1195    }
1196
1197    /// Build a fake mk1 5-bit data stream for round-trip tests:
1198    /// `[v0, v1, ...]` are 2 bech32-symbol single-string-style header
1199    /// symbols; `payload_bytes` is the byte-level fragment.
1200    fn build_5bit_data(header_symbols: &[u8], payload_bytes: &[u8]) -> Vec<u8> {
1201        let mut out = Vec::with_capacity(header_symbols.len() + payload_bytes.len() * 2);
1202        out.extend_from_slice(header_symbols);
1203        out.extend(bytes_to_5bit(payload_bytes));
1204        out
1205    }
1206
1207    #[test]
1208    fn encode_5bit_to_string_round_trip_regular() {
1209        // 2-symbol single-string header + 4-byte payload → 7 5-bit symbols
1210        // (header [v=0, t=0] || bytes_to_5bit(4 bytes) = 2 + 7 = 9 symbols).
1211        // 9 + 13 regular checksum = 22-char data part — well within regular range.
1212        let header_symbols = [0u8, 0u8];
1213        let payload = vec![0xDE, 0xAD, 0xBE, 0xEF];
1214        let data_5bit = build_5bit_data(&header_symbols, &payload);
1215        let s = encode_5bit_to_string(&data_5bit).unwrap();
1216        assert!(s.starts_with("mk1"), "string did not start with mk1: {}", s);
1217
1218        let decoded = decode_string(&s).unwrap();
1219        assert_eq!(decoded.code, BchCode::Regular);
1220        assert_eq!(decoded.corrections_applied, 0);
1221        assert!(decoded.corrected_positions.is_empty());
1222        assert_eq!(decoded.data(), data_5bit.as_slice());
1223
1224        // Recover the payload by stripping the 2-symbol header and byte-decoding.
1225        let payload_5bit = &decoded.data()[2..];
1226        let recovered = five_bit_to_bytes(payload_5bit).unwrap();
1227        assert_eq!(recovered, payload);
1228    }
1229
1230    #[test]
1231    fn encode_5bit_to_string_round_trip_long() {
1232        // Force a long-code path with an 8-symbol chunked-style header +
1233        // a 53-byte fragment: data_5bit.len() = 8 + ceil(53*8/5) = 8 + 85 = 93,
1234        // + 15 long checksum = 108 — exact long-code upper bound.
1235        let header_symbols = [0u8; 8];
1236        let payload = vec![0xA5u8; 53];
1237        let data_5bit = build_5bit_data(&header_symbols, &payload);
1238        assert_eq!(
1239            data_5bit.len(),
1240            93,
1241            "fixture invariant: 8 header + 85 payload symbols"
1242        );
1243        let s = encode_5bit_to_string(&data_5bit).unwrap();
1244        assert!(s.starts_with("mk1"));
1245        let decoded = decode_string(&s).unwrap();
1246        assert_eq!(decoded.code, BchCode::Long);
1247        assert_eq!(decoded.data(), data_5bit.as_slice());
1248
1249        let recovered = five_bit_to_bytes(&decoded.data()[8..]).unwrap();
1250        assert_eq!(recovered, payload);
1251    }
1252
1253    #[test]
1254    fn encode_starts_with_hrp_and_separator() {
1255        // Minimum-shape input: 1 5-bit symbol + 13 regular checksum = 14 — the
1256        // tightest valid regular-code data-part length.
1257        let s = encode_5bit_to_string(&[1u8]).unwrap();
1258        assert!(s.starts_with("mk1"), "string did not start with mk1: {}", s);
1259    }
1260
1261    #[test]
1262    fn decode_rejects_invalid_hrp() {
1263        let s = encode_5bit_to_string(&[0u8; 10]).unwrap();
1264        let bad = s.replacen("mk", "bt", 1);
1265        assert!(matches!(
1266            decode_string(&bad),
1267            Err(crate::Error::InvalidHrp(_))
1268        ));
1269    }
1270
1271    #[test]
1272    fn decode_rejects_mixed_case() {
1273        let s = encode_5bit_to_string(&[0u8; 10]).unwrap();
1274        let bad: String = s
1275            .chars()
1276            .enumerate()
1277            .map(|(i, c)| if i == 5 { c.to_ascii_uppercase() } else { c })
1278            .collect();
1279        assert!(matches!(decode_string(&bad), Err(crate::Error::MixedCase)));
1280    }
1281
1282    #[test]
1283    fn decode_rejects_invalid_char() {
1284        // 'b' is excluded from the bech32 alphabet; substitute one in the data
1285        // part to force a parse-time character rejection.
1286        let s = encode_5bit_to_string(&[0u8; 10]).unwrap();
1287        // s looks like "mk1...". Splice 'b' at index 5 (definitely past "mk1").
1288        let mut chars: Vec<char> = s.chars().collect();
1289        chars[5] = 'b';
1290        let bad: String = chars.into_iter().collect();
1291        assert!(matches!(
1292            decode_string(&bad),
1293            Err(crate::Error::InvalidChar { .. })
1294        ));
1295    }
1296
1297    #[test]
1298    fn decode_rejects_missing_separator() {
1299        // No '1' at all in the string. rfind('1') returns None → InvalidHrp.
1300        let bad = "mknoseparatorhere";
1301        assert!(matches!(
1302            decode_string(bad),
1303            Err(crate::Error::InvalidHrp(_))
1304        ));
1305    }
1306
1307    #[test]
1308    fn decode_recovers_one_error() {
1309        // Encode, corrupt one char in the data part, decode should auto-correct.
1310        let data_5bit = vec![0u8, 0u8, 1, 2, 3, 4, 5];
1311        let s = encode_5bit_to_string(&data_5bit).unwrap();
1312
1313        let mut chars: Vec<char> = s.chars().collect();
1314        // Corrupt position 6 (past "mk1", well within the data part).
1315        let original_char = chars[6];
1316        chars[6] = if original_char == 'q' { 'p' } else { 'q' };
1317        let corrupted: String = chars.into_iter().collect();
1318
1319        let decoded = decode_string(&corrupted).unwrap();
1320        assert_eq!(decoded.corrections_applied, 1);
1321        assert_eq!(decoded.corrected_positions.len(), 1);
1322        assert_eq!(decoded.data(), data_5bit.as_slice());
1323    }
1324
1325    #[test]
1326    fn encode_rejects_data_part_in_reserved_invalid_length_range() {
1327        // For 5-bit data-part lengths 0..=12 (so data_part = 13..=25 with regular
1328        // checksum, or 15..=27 with long), `bch_code_for_length` rejects below 14.
1329        // Empty input → data_5bit.len()=0 → regular_total=13 → None; long_total=15
1330        // → Regular. Wait — regular range starts at 14 not 13.
1331        //
1332        // Actual invariant test: len 0 → regular_total=13 (None, below 14) and
1333        // long_total=15 (Regular). So it falls back to long->regular ladder ...
1334        // Re-checking encode_5bit_to_string: only fails when both miss [14..=93]
1335        // and [96..=108]. For data_5bit.len()=79, regular_total=92 → Regular ✓.
1336        // The provable reserved-invalid case is a length that misses both
1337        // ranges; the BIP 93 BCH ladder leaves no such gap below 109 because
1338        // [14..=93] ∪ [96..=108] only excludes {0..=13, 94..=95, ≥109}. The
1339        // smallest input length that produces invalid data-part lengths in
1340        // BOTH the regular and long branches is therefore data_5bit.len() ≥ 94
1341        // (regular_total ≥ 107 in invalid territory, long_total ≥ 109 too long).
1342        let too_long = vec![0u8; 94];
1343        let result = encode_5bit_to_string(&too_long);
1344        assert!(matches!(result, Err(crate::Error::InvalidStringLength(_))));
1345    }
1346}