Skip to main content

mk_codec/
error.rs

1//! Error type for `mk-codec`.
2//!
3//! Variants mirror the rejection conditions enumerated in
4//! `design/SPEC_mk_v0_1.md` §4 ("Bytecode-Validity Rules") and
5//! `bip/bip-mnemonic-key.mediawiki` §"Decoder validity rules". All
6//! decoder-rejection paths in a future implementation MUST surface
7//! one of these variants. Pre-BIP-submission, every variant is
8//! required to map to at least one named negative test vector
9//! (tracked as `decoder-error-variant-parity` in
10//! `design/FOLLOWUPS.md`).
11
12use bitcoin::bip32::ChildNumber;
13use thiserror::Error;
14
15/// All errors `mk-codec` can produce.
16///
17/// Marked `#[non_exhaustive]` so that future versions can add variants
18/// without breaking external callers' exhaustive `match` arms.
19#[non_exhaustive]
20#[derive(Debug, Error)]
21pub enum Error {
22    // ── String-layer errors (codex32 plumbing, HRP, chunk-header) ───────────
23    /// HRP is not `mk` or input is not a valid bech32-shaped string.
24    #[error("invalid HRP: {0}")]
25    InvalidHrp(String),
26
27    /// Input string mixes ASCII upper- and lower-case in its data part.
28    /// BIP 173 forbids mixed case to remove an entire class of
29    /// transcription ambiguity; the rule is inherited verbatim by mk1's
30    /// codex32-derived encoding.
31    #[error("mixed case in input string")]
32    MixedCase,
33
34    /// Input string's data-part length is not a valid mk1 length:
35    /// either below the regular-code minimum (14 5-bit symbols), in the
36    /// reserved-invalid 94–95 gap between regular and long codes, or
37    /// above the long-code maximum (108). The carried `usize` is the
38    /// observed length; reported pessimistically to highlight which
39    /// boundary the caller missed.
40    #[error("invalid data-part length: {0}")]
41    InvalidStringLength(usize),
42
43    /// Input string's data part contains a character that is not in the
44    /// 32-character bech32 alphabet (`qpzry9x8gf2tvdw0s3jn54khce6mua7l`).
45    /// The offending character and its 0-indexed position within the
46    /// data part are reported so a higher-level decoder report can
47    /// surface a precise location for transcription-error feedback.
48    #[error("invalid character {ch} at position {position}")]
49    InvalidChar {
50        /// The character that was not in the bech32 alphabet.
51        ch: char,
52        /// 0-indexed position within the data part (chars after `mk1`).
53        position: usize,
54    },
55
56    /// BCH checksum could not be corrected within the per-code-variant
57    /// substitution-correction capacity: 4 substitutions for both the
58    /// regular and the long code (`t = 4`). The `8` is the *detection*
59    /// radius (the codes' minimum distance is ≥ 9 — which is what permits
60    /// t = 4 correction AND detection of up to 8 substitutions), not a
61    /// correction capacity; an earlier note saying "8 for the long code"
62    /// conflated detection with correction.
63    #[error("BCH uncorrectable: {0}")]
64    BchUncorrectable(String),
65
66    /// Chunk-header card-type byte is not in {0x00 SingleString, 0x01 Chunked}.
67    /// The 5-bit type field's reserved range 0x02..=0x1F MUST be rejected.
68    #[error("unsupported card type: 0x{0:02x}")]
69    UnsupportedCardType(u8),
70
71    /// 5-bit payload symbols, after BCH verification, do not byte-align
72    /// (i.e., the trailing pad bits of the final 5-bit symbol are non-zero).
73    /// Parallels md1's `MalformedPayloadPadding` rejection.
74    #[error("malformed payload padding (5-bit symbols don't byte-align)")]
75    MalformedPayloadPadding,
76
77    /// For chunked input: chunks have inconsistent `chunk_set_id` values.
78    /// Used at reassembly time to detect mixed-card-set inputs.
79    #[error("chunk_set_id mismatch across chunks")]
80    ChunkSetIdMismatch,
81
82    /// For chunked input: malformed chunked-string header (e.g., total_chunks
83    /// = 0 or > 32, chunk_index >= total_chunks, gaps or duplicates in the
84    /// index sequence at reassembly).
85    #[error("chunked-header malformed: {0}")]
86    ChunkedHeaderMalformed(String),
87
88    /// Decoder received a multi-string input whose `SingleString` and
89    /// `Chunked` header variants disagree across the supplied list:
90    /// either the first string is `SingleString` but additional strings
91    /// follow (caught early in `pipeline::decode`), or the first chunk
92    /// is `Chunked` but a later chunk in the list is `SingleString`
93    /// (caught in `chunk::reassemble_from_chunks`). Distinct from
94    /// [`Error::ChunkedHeaderMalformed`], which covers issues *within*
95    /// a declared-chunked set (bad `chunk_index`, bad `total_chunks`,
96    /// duplicates, gaps, etc.).
97    #[error("mixed string-layer header types in input list")]
98    MixedHeaderTypes,
99
100    /// For chunked input: reassembled bytecode's trailing 4-byte
101    /// `cross_chunk_hash` does not match `SHA-256(canonical_bytecode)[0..4]`.
102    #[error("cross-chunk integrity hash mismatch")]
103    CrossChunkHashMismatch,
104
105    // ── Bytecode-layer errors (after string-layer reassembly) ────────────────
106    /// Bytecode-header version != 0 in v0.1.
107    #[error("unsupported version: {0}")]
108    UnsupportedVersion(u8),
109
110    /// A reserved bit in the bytecode header was set (bits 0, 1, 3 in v0.1;
111    /// bit 2 is the fingerprint flag and is allowed).
112    #[error("reserved bits set in bytecode header")]
113    ReservedBitsSet,
114
115    /// `policy_id_stub_count == 0`. The spec requires ≥ 1.
116    #[error("policy_id_stub_count must be >= 1")]
117    InvalidPolicyIdStubCount,
118
119    /// Origin-path indicator byte is outside the standard table or in the
120    /// reserved range. (Per SPEC §3.5 the reserved indicators are 0x00,
121    /// 0x08-0x10, 0x18-0xFD, and 0xFF. 0x16 — BIP 48 testnet nested-segwit
122    /// multisig, `m/48'/1'/0'/1'` — was reserved-pending an md1 dictionary
123    /// update in v0.1.x but has been ASSIGNED since v0.2.0; see
124    /// `bytecode::path::STANDARD_PATHS` and FOLLOWUPS
125    /// `md-path-dictionary-0x16-gap` (resolved by md-codec-v0.9.0 +
126    /// mk-codec-v0.2.0).)
127    #[error("invalid path indicator byte: 0x{0:02x}")]
128    InvalidPathIndicator(u8),
129
130    /// Explicit path declared `component_count > MAX_PATH_COMPONENTS`
131    /// (closure Q-3 lock: max 10, was 32 in the pre-closure draft).
132    #[error("path too deep: {0} components (max 10)")]
133    PathTooDeep(u8),
134
135    /// A path component's encoded value is invalid (e.g., out of BIP 32
136    /// range, or hardened-bit set in an invalid position).
137    #[error("invalid path component: {0}")]
138    InvalidPathComponent(String),
139
140    /// xpub `version` field doesn't match a known network's xpub prefix.
141    #[error("invalid xpub version: 0x{0:08x}")]
142    InvalidXpubVersion(u32),
143
144    /// xpub `public_key` bytes do not parse as a valid compressed
145    /// secp256k1 point. Realistically unreachable for inputs that
146    /// pass BCH verification; surfaces hand-constructed inputs.
147    #[error("invalid xpub public key: {0}")]
148    InvalidXpubPublicKey(String),
149
150    /// Decoder hit end-of-stream mid-field.
151    #[error("unexpected end of bytecode")]
152    UnexpectedEnd,
153
154    /// Decoder finished consuming all expected fields but bytes remain.
155    #[error("trailing bytes after xpub")]
156    TrailingBytes,
157
158    /// Canonical bytecode + cross-chunk hash exceeds the v0.1 capacity
159    /// of `MAX_CHUNKS * CHUNKED_FRAGMENT_LONG_BYTES − CROSS_CHUNK_HASH_BYTES`
160    /// (= 32 × 53 − 4 = 1692 bytes). Reachable only through pathological
161    /// hand-constructed inputs; typical mk1 cards land well below this
162    /// ceiling per `design/SPEC_mk_v0_1.md` §2.4.
163    #[error(
164        "card payload too large: bytecode_len = {bytecode_len} > max_supported = {max_supported}"
165    )]
166    CardPayloadTooLarge {
167        /// Observed canonical-bytecode length in bytes.
168        bytecode_len: usize,
169        /// Maximum bytecode length the v0.1 chunking layer can carry.
170        max_supported: usize,
171    },
172
173    /// Encoder-side invariant: the supplied `xpub`'s BIP-32 `depth` /
174    /// `child_number` disagree with `origin_path` (`depth ≠` component
175    /// count, or `child_number ≠` the terminal component). Compact-73
176    /// reconstructs both fields from the path on decode, so emitting such a
177    /// card would yield a different-metadata xpub. Rejected at encode to keep
178    /// compact-73 genuinely lossless. The decoder cannot detect this (no
179    /// on-wire depth) — see `design/SPEC_mk_v0_1.md` §4 (encoder-side
180    /// invariant) and `design/SPEC_mk_depth_child_enforcement.md`.
181    #[error(
182        "xpub origin-path mismatch: xpub depth {xpub_depth} / child {xpub_child} \
183         vs origin_path depth {path_depth} / last {path_child:?}"
184    )]
185    XpubOriginPathMismatch {
186        /// `xpub.depth` as supplied.
187        xpub_depth: u8,
188        /// `component_count(origin_path)`.
189        path_depth: u8,
190        /// `xpub.child_number` as supplied.
191        xpub_child: ChildNumber,
192        /// Terminal component of `origin_path` (`None` for an empty path).
193        path_child: Option<ChildNumber>,
194    },
195}
196
197/// `Result` alias used throughout `mk-codec`.
198pub type Result<T> = core::result::Result<T, Error>;
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    /// Each variant carries enough information for its rendered Display
205    /// to be diagnostic. Sanity-check the format strings render
206    /// correctly for every parameterized variant.
207    #[test]
208    fn parameterized_variants_render() {
209        let cases: Vec<(Error, &str)> = vec![
210            (Error::InvalidHrp("mq".into()), "invalid HRP: mq"),
211            (
212                Error::BchUncorrectable(
213                    "5 substitutions exceed long-code 4-correction limit".into(),
214                ),
215                "BCH uncorrectable: 5 substitutions exceed long-code 4-correction limit",
216            ),
217            (
218                Error::UnsupportedCardType(0x05),
219                "unsupported card type: 0x05",
220            ),
221            (
222                Error::ChunkedHeaderMalformed("total_chunks = 0".into()),
223                "chunked-header malformed: total_chunks = 0",
224            ),
225            (
226                Error::InvalidXpubPublicKey("malformed compressed point".into()),
227                "invalid xpub public key: malformed compressed point",
228            ),
229            (Error::UnsupportedVersion(1), "unsupported version: 1"),
230            (
231                Error::InvalidPathIndicator(0x16),
232                "invalid path indicator byte: 0x16",
233            ),
234            (
235                Error::PathTooDeep(11),
236                "path too deep: 11 components (max 10)",
237            ),
238            (
239                Error::InvalidPathComponent("LEB128 overflow at component 3".into()),
240                "invalid path component: LEB128 overflow at component 3",
241            ),
242            (
243                Error::InvalidXpubVersion(0xDEADBEEF),
244                "invalid xpub version: 0xdeadbeef",
245            ),
246        ];
247        for (err, expected) in cases {
248            assert_eq!(format!("{err}"), expected);
249        }
250    }
251
252    // ── String-layer rejection coverage (per plan §3.2.4) ──────────────
253    //
254    // Phase 5 landed the string-layer code paths that produce
255    // `CrossChunkHashMismatch`, `MalformedPayloadPadding`,
256    // `ChunkSetIdMismatch`, and `ChunkedHeaderMalformed`. The detailed
257    // reject scenarios live in `crate::string_layer::pipeline::tests`
258    // and `crate::string_layer::chunk::tests`; the smoke checks here
259    // assert that each variant is reachable through the public
260    // `crate::decode` API rather than just the lower-level layer
261    // helpers (the scaffolds documented in the plan §3.2.4 forward-
262    // reference these tests).
263    //
264    // (Phase 4 retired the proposed `FingerprintFlagMismatch` variant:
265    // structurally undetectable in the decoder under the closure-locked
266    // wire format, since no length prefix lets the decoder distinguish
267    // "flag set, fp present" from "flag unset, fp omitted." SPEC §4
268    // rule 3 was reframed as an encoder-side invariant; see commit
269    // log for Phase 4 review fixup.)
270
271    /// Unparameterized variants render their static message verbatim.
272    #[test]
273    fn static_variants_render() {
274        assert_eq!(
275            format!("{}", Error::ReservedBitsSet),
276            "reserved bits set in bytecode header",
277        );
278        assert_eq!(
279            format!("{}", Error::CrossChunkHashMismatch),
280            "cross-chunk integrity hash mismatch",
281        );
282        assert_eq!(
283            format!("{}", Error::ChunkSetIdMismatch),
284            "chunk_set_id mismatch across chunks",
285        );
286        assert_eq!(
287            format!("{}", Error::MixedHeaderTypes),
288            "mixed string-layer header types in input list",
289        );
290        assert_eq!(
291            format!("{}", Error::MalformedPayloadPadding),
292            "malformed payload padding (5-bit symbols don't byte-align)",
293        );
294        assert_eq!(
295            format!("{}", Error::InvalidPolicyIdStubCount),
296            "policy_id_stub_count must be >= 1",
297        );
298        assert_eq!(
299            format!("{}", Error::UnexpectedEnd),
300            "unexpected end of bytecode",
301        );
302        assert_eq!(
303            format!("{}", Error::TrailingBytes),
304            "trailing bytes after xpub",
305        );
306    }
307}