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