Skip to main content

md_codec/
error.rs

1//! Error variants for the md-codec wire-format codec.
2
3use thiserror::Error;
4
5/// Operator-context kind — where in the descriptor tree an operator appears.
6/// Per SPEC v0.30 §11. Carried by [`Error::OperatorContextViolation`] to name
7/// which tree-position a forbidden tag was encountered in.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ContextKind {
10    /// Top-level descriptor position (e.g., bare `PkK` as the descriptor root).
11    TopLevel,
12    /// Inside a `tr()` tap-script leaf (BIP-342 tapscript-only operators).
13    TapLeaf,
14    /// Inside a multi-family body (non-key tag among multi children).
15    MultiBody,
16}
17
18/// Errors produced by md-codec wire-format components.
19#[derive(Debug, Error, PartialEq, Eq)]
20pub enum Error {
21    /// A read of `requested` bits was attempted but only `available` bits remained.
22    #[error("attempted to read {requested} bits with only {available} bits remaining")]
23    BitStreamTruncated {
24        /// Number of bits the caller requested.
25        requested: usize,
26        /// Number of bits actually available in the stream.
27        available: usize,
28    },
29
30    /// Wire-format version field doesn't match v0.30 (=4). Returned when a
31    /// payload or chunk-header is read with a version value outside the
32    /// accepted v0.30 set. Per SPEC v0.30 §2.4 + §2.5 + §11.1.
33    #[error("wire-format version mismatch: got {got}, expected 4")]
34    WireVersionMismatch {
35        /// Version value parsed from the wire.
36        got: u8,
37    },
38
39    /// Header malformed in a way other than version mismatch — e.g., chunked-
40    /// flag inconsistent with caller context, or chunk-header internal field
41    /// out of range. Per SPEC v0.30 §11.1.
42    #[error("malformed header: {detail}")]
43    MalformedHeader {
44        /// Free-form description of the malformedness.
45        detail: String,
46    },
47
48    /// Path depth exceeds MAX_PATH_COMPONENTS (15).
49    #[error("path depth {got} exceeds maximum {max}")]
50    PathDepthExceeded {
51        /// Actual depth of the path.
52        got: usize,
53        /// Maximum allowed depth (15).
54        max: usize,
55    },
56
57    /// Key count `n` out of range. Per SPEC v0.30 §4: `1 ≤ n ≤ 32`.
58    #[error("key count {n} out of range; require 1 ≤ n ≤ 32")]
59    KeyCountOutOfRange {
60        /// Actual key count provided.
61        n: u8,
62    },
63
64    /// Divergent path count doesn't match key count.
65    #[error("divergent path count {got} does not match key count {n}")]
66    DivergentPathCountMismatch {
67        /// Expected key count.
68        n: u8,
69        /// Actual path count provided.
70        got: usize,
71    },
72
73    /// Multipath alt-count out of range. Per SPEC v0.30 §8: `2 ≤ count ≤ 9`.
74    #[error("multipath alt-count {got} out of range; require 2 ≤ count ≤ 9")]
75    AltCountOutOfRange {
76        /// Provided alt-count.
77        got: usize,
78    },
79
80    /// Tag value outside the allocated v0.30 set: 6-bit primary in reserved
81    /// range 0x24..=0x3E, or extension prefix 0x3F followed by an unrecognized
82    /// 4-bit subcode 0x00..=0x0F (the entire extension subspace is reserved
83    /// in v0.30). `primary` carries the raw 6-bit value read off the wire
84    /// (0x3F for extension-subspace failures); the 4-bit subcode is consumed
85    /// but not reported. Per SPEC v0.30 §3.2 and §11.1.
86    #[error("tag value 0x{primary:02x} out of range")]
87    TagOutOfRange {
88        /// The raw 6-bit primary value read off the wire.
89        primary: u8,
90    },
91
92    /// Threshold `k` out of range. Per SPEC v0.30 §4: `1 ≤ k ≤ 32`.
93    #[error("threshold k={k} out of range; require 1 ≤ k ≤ 32")]
94    ThresholdOutOfRange {
95        /// Provided k value.
96        k: u8,
97    },
98
99    /// Variable-arity child count out of range. Per SPEC v0.30 §4: `1 ≤ count ≤ 32`.
100    #[error("child count {count} out of range; require 1 ≤ count ≤ 32")]
101    ChildCountOutOfRange {
102        /// Provided child count.
103        count: usize,
104    },
105
106    /// k > n in k-of-n threshold/multisig.
107    #[error("threshold k={k} exceeds child count n={n}; require k ≤ n")]
108    KGreaterThanN {
109        /// Threshold k.
110        k: u8,
111        /// Child count n.
112        n: usize,
113    },
114
115    /// TLV ordering violation: a TLV tag was followed by a smaller-or-equal tag.
116    #[error(
117        "TLV ordering violation: tag 0x{prev:02x} followed by 0x{current:02x}; require ascending"
118    )]
119    TlvOrderingViolation {
120        /// Previous tag value.
121        prev: u8,
122        /// Current tag value.
123        current: u8,
124    },
125
126    /// Placeholder index in TLV entry exceeds key count n.
127    #[error("placeholder index {idx} out of range; require idx < n={n}")]
128    PlaceholderIndexOutOfRange {
129        /// Provided index.
130        idx: u8,
131        /// Key count n.
132        n: u8,
133    },
134
135    /// Per-`@N` override entries within a TLV must be in ascending `@N`-index order.
136    #[error("override ordering violation: @{prev} followed by @{current}; require ascending")]
137    OverrideOrderViolation {
138        /// Previous index.
139        prev: u8,
140        /// Current index.
141        current: u8,
142    },
143
144    /// TLV entry has zero entries; encoder MUST omit empty TLVs per spec §7.5.
145    #[error("TLV entry tag 0x{tag:02x} has empty payload; encoder MUST omit empty TLVs")]
146    EmptyTlvEntry {
147        /// Tag of the empty entry.
148        tag: u8,
149    },
150
151    /// TLV length exceeds remaining bits in stream.
152    #[error("TLV length {length} exceeds remaining bits {remaining}")]
153    TlvLengthExceedsRemaining {
154        /// Declared length.
155        length: usize,
156        /// Available bits.
157        remaining: usize,
158    },
159
160    /// Placeholder @i was not referenced anywhere in the tree (BIP 388 well-formedness).
161    #[error("placeholder @{idx} not referenced in tree; n={n}")]
162    PlaceholderNotReferenced {
163        /// The unreferenced placeholder index.
164        idx: u8,
165        /// Key count.
166        n: u8,
167    },
168
169    /// First-occurrence ordering violated (BIP 388 well-formedness).
170    #[error(
171        "placeholder first-occurrence ordering violated: expected first={expected_first}, got first={got_first}"
172    )]
173    PlaceholderFirstOccurrenceOutOfOrder {
174        /// Expected placeholder index in canonical first-occurrence position.
175        expected_first: u8,
176        /// Actual placeholder index encountered first.
177        got_first: u8,
178    },
179
180    /// All multipaths in a template must share the same alt-count.
181    #[error("multipath alt-count mismatch: expected {expected}, got {got}")]
182    MultipathAltCountMismatch {
183        /// Expected alt-count.
184        expected: usize,
185        /// Mismatched alt-count.
186        got: usize,
187    },
188
189    /// A `use_site_path_overrides` entry was keyed on `@0`. `@0` is the
190    /// canonical baseline (`Descriptor::use_site_path`) and cannot be
191    /// overridden — an `@0` entry is a non-canonical / adversarial wire
192    /// (our encoders only push overrides for `i ≥ 1`). Per the D5(a) decode
193    /// canonical-form check (`restore-md1-per-key-use-site` SPEC §4.1).
194    #[error("use-site override keyed on baseline @{idx}; @0 cannot be overridden")]
195    BaselineUseSiteOverride {
196        /// The offending index (always 0).
197        idx: u8,
198    },
199
200    /// A `use_site_path_overrides` entry's `UseSitePath` equaled the
201    /// resolved baseline (`Descriptor::use_site_path`). A redundant override
202    /// is non-canonical (our encoders push an override only when it DIFFERS
203    /// from the baseline) and is rejected at decode. Per the D5(a) decode
204    /// canonical-form check.
205    #[error("redundant use-site override for @{idx}; equals the baseline use-site path")]
206    RedundantUseSiteOverride {
207        /// The placeholder index whose override duplicates the baseline.
208        idx: u8,
209    },
210
211    /// Tap-script-tree leaf has a tag that is forbidden per spec §6.3.1.
212    #[error("forbidden tap-script-tree leaf tag: 0x{tag:02x}")]
213    ForbiddenTapTreeLeaf {
214        /// Primary 6-bit tag code (bytecode space) of the forbidden leaf.
215        tag: u8,
216    },
217
218    /// Operator appears in a forbidden context per SPEC v0.30 §11.
219    /// `TopLevel` is enforced decoder-side at `decode_payload`; `TapLeaf` is
220    /// covered by the narrower [`Error::ForbiddenTapTreeLeaf`]; `MultiBody` is
221    /// structurally unreachable post-v0.30 Phase C (multi-family bodies carry
222    /// raw kiw-bit indices, not child tags).
223    #[error("operator {tag:?} not allowed in context {context:?}")]
224    OperatorContextViolation {
225        /// The offending operator tag.
226        tag: crate::tag::Tag,
227        /// Which tree-position the tag is forbidden in.
228        context: ContextKind,
229    },
230
231    /// Chunk count out of range. Per SPEC v0.30 §2.5: `1 ≤ count ≤ 64`.
232    #[error("chunk count {count} out of range; require 1 ≤ count ≤ 64")]
233    ChunkCountOutOfRange {
234        /// Provided count.
235        count: u8,
236    },
237
238    /// Chunk index ≥ count; require index < count.
239    #[error("chunk index {index} ≥ count {count}")]
240    ChunkIndexOutOfRange {
241        /// Provided index.
242        index: u8,
243        /// Provided count.
244        count: u8,
245    },
246
247    /// Chunk-set-id exceeds 20-bit range.
248    #[error("chunk-set-id 0x{id:x} exceeds 20-bit range")]
249    ChunkSetIdOutOfRange {
250        /// Provided ID.
251        id: u32,
252    },
253
254    /// Chunk header missing chunked-flag. Per SPEC v0.30 §2.2: bit 0 of the
255    /// first 5-bit symbol of a chunked payload is the chunked-flag (followed
256    /// by the 4-bit version field); it MUST be 1 in every chunk header.
257    #[error("chunk header chunked-flag missing; per SPEC §2.2 bit 0 of the first symbol must be 1")]
258    ChunkHeaderChunkedFlagMissing,
259
260    /// Encoding requires more chunks than the spec maximum (64).
261    #[error("encoding requires {needed} chunks; max is 64 per spec §9.8")]
262    ChunkCountExceedsMax {
263        /// Number of chunks needed.
264        needed: usize,
265    },
266
267    /// Codex32 decode error (HRP mismatch, alphabet violation, BCH verification failure).
268    #[error("codex32 decode error: {0}")]
269    Codex32DecodeError(String),
270
271    /// Codex32 encode error (BCH layer failure).
272    #[error("codex32 encode error: {0}")]
273    Codex32EncodeError(String),
274
275    /// Chunk set is empty (no strings provided to reassemble).
276    #[error("chunk set is empty (no strings provided)")]
277    ChunkSetEmpty,
278
279    /// Chunks in the set disagree on version, chunk-set-id, or count.
280    #[error("chunks in the set disagree on version, chunk-set-id, or count")]
281    ChunkSetInconsistent,
282
283    /// Chunk set incomplete: got fewer chunks than `expected`.
284    #[error("chunk set incomplete: got {got} chunks, expected {expected}")]
285    ChunkSetIncomplete {
286        /// Provided chunk count.
287        got: usize,
288        /// Expected chunk count.
289        expected: usize,
290    },
291
292    /// Chunk index gap: expected index N, got M.
293    #[error("chunk index gap: expected index {expected}, got {got}")]
294    ChunkIndexGap {
295        /// Expected index in the sequence.
296        expected: u8,
297        /// Actual index encountered.
298        got: u8,
299    },
300
301    /// Chunk-set-id mismatch between expected and reassembled-then-derived.
302    #[error("chunk-set-id mismatch: expected 0x{expected:x}, derived 0x{derived:x}")]
303    ChunkSetIdMismatch {
304        /// Expected (from chunks).
305        expected: u32,
306        /// Derived (from reassembled payload).
307        derived: u32,
308    },
309
310    /// LP4-ext varint value exceeds single-extension payload range (29 bits).
311    #[error("varint value {value} exceeds single-extension range (max 2^29 - 1)")]
312    VarintOverflow {
313        /// The offending value.
314        value: u32,
315    },
316
317    /// A non-canonical wrapper has no explicit origin path for some `@N`,
318    /// either via `OriginPathOverrides` or a populated `path_decl` entry,
319    /// and `canonical_origin(&d.tree)` is `None`. Per spec v0.13 §6.3.
320    #[error("non-canonical wrapper requires explicit origin for @{idx}, but none provided")]
321    MissingExplicitOrigin {
322        /// The placeholder index for which an explicit origin is required.
323        idx: u8,
324    },
325
326    /// `presence_byte` had non-zero reserved bits (bits 2..7) inside a
327    /// `WalletPolicyId` canonical-record preimage. Per spec v0.13 §5.3:
328    /// encoders MUST set reserved bits to 0 and decoders MUST reject
329    /// inputs with non-zero reserved bits. v0.13's encoder masks reserved
330    /// bits explicitly when building the hash preimage; the helper
331    /// [`crate::identity::validate_presence_byte`] enforces the
332    /// decoder-side contract for canonical-record consumers.
333    #[error("WalletPolicyId presence_byte has non-zero reserved bits: 0x{reserved_bits:02x}")]
334    InvalidPresenceByte {
335        /// The reserved-bit field (bits 2..7) of the offending presence byte.
336        reserved_bits: u8,
337    },
338
339    /// A `Pubkeys` TLV entry's 33-byte compressed-pubkey field (bytes
340    /// 32..65 of the 65-byte xpub payload) failed to parse as a valid
341    /// secp256k1 point. The 32-byte chain code prefix is unvalidated.
342    /// Per spec v0.13 §6.4.
343    #[error("invalid xpub bytes for @{idx}: pubkey field is not a valid secp256k1 point")]
344    InvalidXpubBytes {
345        /// The placeholder index whose xpub failed to parse.
346        idx: u8,
347    },
348    /// Address derivation requires a populated `Pubkeys` TLV entry for
349    /// every `@N`; this descriptor is missing one (template-only or
350    /// partial-keys mode). v0.14+ derivation surface only.
351    #[error(
352        "missing xpub for @{idx}; address derivation requires wallet-policy mode with all @N populated"
353    )]
354    MissingPubkey {
355        /// The placeholder index whose xpub is absent.
356        idx: u8,
357    },
358
359    /// `Descriptor::derive_address` was called with a `chain` index
360    /// outside the use-site multipath alt-count (or non-zero when no
361    /// multipath is present).
362    #[error("chain index {chain} out of range; use-site multipath alt-count is {alt_count}")]
363    ChainIndexOutOfRange {
364        /// The provided chain index.
365        chain: u32,
366        /// The number of alternatives in the use-site multipath (`0` when
367        /// no multipath component is present).
368        alt_count: usize,
369    },
370
371    /// Address derivation requires non-hardened use-site components,
372    /// but this descriptor's use-site path declares a hardened
373    /// alternative or hardened wildcard. BIP 32 forbids hardened
374    /// derivation from a public key, so an xpub-only restore cannot
375    /// produce addresses for this wallet.
376    #[error(
377        "hardened public-key derivation: use-site path requires hardened component, which BIP 32 forbids on xpub-only restore"
378    )]
379    HardenedPublicDerivation,
380
381    /// Address derivation failed at the miniscript layer (or in the
382    /// AST → miniscript converter). Carries a free-form `detail` string
383    /// describing the underlying error — typically a `miniscript::Error`,
384    /// a `Tr`/`Wsh` constructor failure (type-check / context error), or
385    /// an arity/context mismatch raised by the converter.
386    #[error("address derivation failed: {detail}")]
387    AddressDerivationFailed {
388        /// Free-form description of the underlying failure.
389        detail: String,
390    },
391
392    /// Inside a `tr()` body, `is_nums = false` was paired with a `key_index`
393    /// out of range (`key_index >= n`). Per SPEC v0.30 §7 + §11: the
394    /// placeholder-index range is `0..n` strictly; the v0.x NUMS sentinel
395    /// slot at `key_index = n` is gone (NUMS is now flag-driven via
396    /// `Body::Tr.is_nums`). Raised by `validate_placeholder_usage` when the
397    /// in-`tr()` overflow condition is hit.
398    #[error("NUMS sentinel conflict: is_nums=false with key_index out of range (SPEC §7 §11)")]
399    NUMSSentinelConflict,
400
401    /// Decode-side recursion depth exceeded the hardening cap.
402    /// `read_node` calls itself recursively for tags with child bodies
403    /// (`Tag::Sh`, `Tag::AndV`, `Tag::TapTree`, `Tag::Multi`, `Tag::Tr`,
404    /// etc.); a hostile wire payload nesting these tags arbitrarily deep
405    /// would blow the Rust stack. The cap is shared across all recursive
406    /// tags as a generic anti-DOS hardening bound. v0.19 introduced.
407    #[error("decode recursion depth {depth} exceeded maximum {max}")]
408    DecodeRecursionDepthExceeded {
409        /// Current recursion depth at which the cap fired.
410        depth: u8,
411        /// Maximum allowed depth.
412        max: u8,
413    },
414
415    /// BCH correction capacity exceeded: a chunk's syndrome pattern indicated
416    /// more errors than the BCH(93, 80, 8) code can correct. F-A9: the
417    /// *correction* capacity is `t = 4` substitution errors; the code's
418    /// `2t = 8` figure is its *detection* radius (the singleton bound), NOT the
419    /// number of correctable errors — the two must not be conflated. A pattern
420    /// beyond `t = 4` has no unique correction. v0.34.0 introduced; raised by
421    /// [`crate::decode_with_correction`]. Atomic per plan §1 D28: any chunk
422    /// failing this check fails the whole multi-chunk call without partial
423    /// output.
424    #[error(
425        "chunk {chunk_index} exceeds the BCH correction capacity of t=4 substitution errors; uncorrectable"
426    )]
427    TooManyErrors {
428        /// 0-indexed position of the offending chunk in the caller's
429        /// `&[&str]` slice.
430        chunk_index: usize,
431        /// The BCH singleton (detection) bound `2t = 8`. Note this is the
432        /// detection radius, not the `t = 4` correction capacity stated in the
433        /// user-facing message; the field is retained for callers/tests that
434        /// pin the code's `2t` parameter.
435        bound: u8,
436    },
437
438    /// Encode-side cap (cycle-4 H6): a single codex32 string's data part
439    /// exceeded the regular code's `REGULAR_DATA_SYMBOLS_MAX = 80` symbols.
440    /// The codex32 regular code is BCH(93, 80, 8); a single string therefore
441    /// carries at most 80 data symbols + 13 checksum = 93. `wrap_payload`
442    /// rejects an over-length payload (fail-closed) rather than emit an
443    /// un-decodable / aliasing-prone single string — callers needing more
444    /// capacity must use chunked encoding (`--force-chunked` / `split()`).
445    #[error(
446        "payload is {data_symbols} data symbols; the codex32 regular code caps single strings at {max} (use chunked encoding / --force-chunked)"
447    )]
448    PayloadTooLongForSingleString {
449        /// The over-length data-symbol count actually computed.
450        data_symbols: usize,
451        /// The maximum legal data-symbol count (80).
452        max: usize,
453    },
454
455    /// Decode-side cap, correcting path (cycle-4 M4): a chunk handed to the
456    /// BCH-correcting decoder (`decode_with_correction`) had more than 93
457    /// symbols. The codex32 regular code's generator `β` has order 93, so a
458    /// degree `d` and `d + 93` alias in `chien_search` for an over-93-symbol
459    /// word — the correcting decoder would mis-correct at an aliased root.
460    /// Reject the out-of-domain chunk before correction (fail-closed).
461    #[error(
462        "chunk {chunk_index} has {symbols} symbols; the codex32 regular code caps a string at {max}"
463    )]
464    ChunkSymbolCountOutOfRange {
465        /// 0-indexed position of the offending chunk in the caller's slice.
466        chunk_index: usize,
467        /// The over-length symbol count actually supplied.
468        symbols: usize,
469        /// The maximum legal codeword length (93).
470        max: usize,
471    },
472
473    /// Decode-side cap, non-correcting path (cycle-4 I1 / §5.2.3): a single
474    /// md1 string handed to the non-correcting primitive (`unwrap_string` /
475    /// `decode_md1_string`) had more than 93 symbols. A clean (residue == 0)
476    /// over-length word is BCH-verifiable by the length-agnostic
477    /// `bch_verify_regular` but is structurally out-of-domain for the regular
478    /// code; reject it before BCH verification (fail-closed). No chunk index
479    /// (single string, not a chunk).
480    #[error("string has {symbols} symbols; the codex32 regular code caps a string at {max}")]
481    StringSymbolCountOutOfRange {
482        /// The over-length symbol count actually supplied.
483        symbols: usize,
484        /// The maximum legal codeword length (93).
485        max: usize,
486    },
487
488    /// F-A8: the ≤7 trailing bits after the last TLV entry (or after the tree
489    /// when no TLVs are present) are byte-padding bits that the reference
490    /// encoder ALWAYS emits as zero (BitWriter + `wrap_payload` zero-pad to the
491    /// next byte boundary). A non-zero trailing pad is a malformed / hand-forged
492    /// wire, never produced by our encoders; the TLV rollback rejects it
493    /// (fail-closed) instead of silently discarding the non-zero bits. This is
494    /// the real error variant the BIP's §Padding rule cites for a non-zero
495    /// trailing pad (F-A8 / DG-5).
496    #[error("malformed payload padding: {bits} trailing pad bit(s) were not all zero")]
497    MalformedPayloadPadding {
498        /// Number of trailing pad bits inspected (1..=7).
499        bits: usize,
500    },
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::tag::Tag;
507
508    /// SPEC v0.30 §11: `OperatorContextViolation` carries the offending tag
509    /// + a `ContextKind` discriminator. Pins the type shape and Display
510    ///   output against future drift; does NOT claim live wire reachability
511    ///   (see FOLLOWUP `v0.30-phase-g-operator-context-violation-unwired`).
512    #[test]
513    fn operator_context_violation_constructs() {
514        let err = Error::OperatorContextViolation {
515            tag: Tag::Multi,
516            context: ContextKind::MultiBody,
517        };
518        let s = err.to_string();
519        assert!(s.contains("Multi"), "Display must mention tag: {s}");
520        assert!(s.contains("MultiBody"), "Display must mention context: {s}");
521    }
522
523    /// SPEC v0.30 §7 + §11: `NUMSSentinelConflict` Display pins the SPEC-cite
524    /// substring so the doc-comment + format string don't silently drift.
525    #[test]
526    fn nums_sentinel_conflict_display() {
527        let s = Error::NUMSSentinelConflict.to_string();
528        assert!(s.contains("§7"), "Display must cite SPEC §7: {s}");
529        assert!(s.contains("§11"), "Display must cite SPEC §11: {s}");
530    }
531
532    /// F-A9: `TooManyErrors` Display must state the correction capacity
533    /// `t = 4`, not conflate it with the `2t = 8` detection radius. The old
534    /// "more than 8 errors" text read as if 8 substitutions were correctable.
535    #[test]
536    fn too_many_errors_message_states_correction_capacity() {
537        let s = Error::TooManyErrors {
538            chunk_index: 0,
539            bound: 8,
540        }
541        .to_string();
542        assert!(
543            s.contains("t=4") || s.contains("t = 4"),
544            "Display must state correction capacity t=4: {s}"
545        );
546        assert!(
547            !s.contains("more than 8"),
548            "Display must not conflate the 2t=8 detection radius with correction: {s}"
549        );
550    }
551}