md_codec/decode.rs
1//! Top-level decoder per spec §13.2.
2
3use crate::bitstream::BitReader;
4use crate::encode::Descriptor;
5use crate::error::{ContextKind, Error};
6use crate::header::Header;
7use crate::origin_path::PathDecl;
8use crate::tag::Tag;
9use crate::tlv::TlvSection;
10use crate::tree::read_node;
11use crate::use_site_path::UseSitePath;
12
13/// Options controlling decode-time validation strictness (P0 pathless/
14/// dead-card partial-decode).
15///
16/// The default (`allow_unresolved_origin: false`) is BYTE-IDENTICAL to
17/// pre-P0 decode behavior: `Error::MissingExplicitOrigin` is raised for
18/// any `@N` whose origin cannot be resolved (no canonical default AND no
19/// explicit `path_decl`/override). When `true`, that ONE reject is
20/// swallowed and the decode succeeds; the caller MUST query
21/// [`crate::encode::Descriptor::unresolved_origin_indices`] on the
22/// returned descriptor to learn which `@N` are unresolved (nothing extra
23/// is recorded on the `Descriptor` itself).
24///
25/// INVARIANT (funds-load-bearing): this flag relaxes ONLY the
26/// `validate_explicit_origin_required` outcome. No other decode check is
27/// affected — placeholder-usage, multipath consistency, tap-script-tree
28/// leaf validity, xpub-bytes validity, and (via
29/// [`crate::chunk::reassemble_with_opts`]) per-chunk BCH, chunk-header
30/// consistency, index-gap, and the derived-chunk-set-id / content-id
31/// check all stay enforced regardless of this flag. The
32/// `Error::EmptyOriginOverride` reject (P0.3) is likewise a DISTINCT,
33/// always-fatal error class — never swallowed by this opt-in, even when
34/// `allow_unresolved_origin` is `true` (fatal-in-partial).
35///
36/// `#[non_exhaustive]` (API-freeze, M-2): a future decode-relaxation option
37/// can be added as a new field without a SemVer-major bump. Downstream
38/// crates therefore cannot construct this with a struct literal — use
39/// [`DecodeOpts::default`] (strict) or [`DecodeOpts::partial`]
40/// (partial-allowing) instead.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42#[non_exhaustive]
43pub struct DecodeOpts {
44 /// When `true`, `Error::MissingExplicitOrigin` is not raised at
45 /// decode time. Default `false` (strict — today's behavior).
46 pub allow_unresolved_origin: bool,
47}
48
49impl DecodeOpts {
50 /// Partial-allowing decode options (`allow_unresolved_origin: true`),
51 /// all other (future) options at their `Default`. The stable
52 /// constructor for the render-path opt-in (`md decode`/`md inspect`,
53 /// toolkit `verify-bundle` gate) — preferred over a struct literal
54 /// since `DecodeOpts` is `#[non_exhaustive]`.
55 pub fn partial() -> Self {
56 Self {
57 allow_unresolved_origin: true,
58 }
59 }
60}
61
62/// Decode a Descriptor from the canonical payload bit stream (strict:
63/// byte-identical to pre-P0 behavior). Delegates to
64/// [`decode_payload_with_opts`] with the default (strict) options.
65/// `bytes` may be zero-padded; `total_bits` is the exact payload bit count.
66pub fn decode_payload(bytes: &[u8], total_bits: usize) -> Result<Descriptor, Error> {
67 decode_payload_with_opts(bytes, total_bits, DecodeOpts::default())
68}
69
70/// Decode a Descriptor from the canonical payload bit stream, honoring
71/// `opts` (P0 partial-decode; see [`DecodeOpts`] for the contract).
72/// `bytes` may be zero-padded; `total_bits` is the exact payload bit count.
73pub fn decode_payload_with_opts(
74 bytes: &[u8],
75 total_bits: usize,
76 opts: DecodeOpts,
77) -> Result<Descriptor, Error> {
78 let mut r = BitReader::with_bit_limit(bytes, total_bits);
79
80 let header = Header::read(&mut r)?;
81 let path_decl = PathDecl::read(&mut r, header.divergent_paths)?;
82 let use_site_path = UseSitePath::read(&mut r)?;
83 // SPEC v0.30 §7 width formula: ⌈log₂(n)⌉. v0.30 drops the +1 v0.18 used
84 // to reserve the NUMS sentinel slot — NUMS is now signalled by an
85 // explicit `is_nums` bit on Body::Tr. MUST mirror
86 // `Descriptor::key_index_width` exactly; a stale formula silently
87 // desyncs the bitstream.
88 let key_index_width = (32 - (path_decl.n as u32).saturating_sub(1).leading_zeros()) as u8;
89 let tree = read_node(&mut r, key_index_width)?;
90
91 // SPEC §11: root tag MUST be in {Sh, Wsh, Wpkh, Pkh, Tr} (the wrapper-tag
92 // allow-list — structural body validation for `Sh`/`Wsh` is separate).
93 // Decoder-side hardening (defense in depth) — the parser-side enforces this
94 // for CLI/template inputs; this catches malformed wires that bypass the
95 // parser via direct bitstream construction. Note: `Sh` covers both
96 // `sh(multi)` and `sh(wsh(multi))` which are distinct BIP-388 shapes sharing
97 // the same root tag; per-shape validation happens at the policy layer.
98 if !matches!(
99 tree.tag,
100 Tag::Sh | Tag::Wsh | Tag::Wpkh | Tag::Pkh | Tag::Tr
101 ) {
102 return Err(Error::OperatorContextViolation {
103 tag: tree.tag,
104 context: ContextKind::TopLevel,
105 });
106 }
107
108 let tlv = TlvSection::read(&mut r, key_index_width, path_decl.n)?;
109
110 let descriptor = Descriptor {
111 n: path_decl.n,
112 path_decl,
113 use_site_path,
114 tree,
115 tlv,
116 };
117
118 crate::validate::validate_placeholder_usage(&descriptor.tree, descriptor.n)?;
119 if let Some(overrides) = &descriptor.tlv.use_site_path_overrides {
120 crate::validate::validate_multipath_consistency(&descriptor.use_site_path, overrides)?;
121 // D5(a): reject non-canonical override shapes (an `@0` override, or a
122 // redundant override equal to the baseline) — never emitted by our
123 // encoders; defense-in-depth against hand-crafted wire.
124 crate::validate::validate_use_site_overrides_canonical(
125 &descriptor.use_site_path,
126 overrides,
127 )?;
128 }
129 if matches!(descriptor.tree.tag, crate::tag::Tag::Tr) {
130 if let crate::tree::Body::Tr { tree: Some(t), .. } = &descriptor.tree.body {
131 crate::validate::validate_tap_script_tree(t)?;
132 }
133 }
134 // Spec v0.13 §6.3 + §6.4: enforce explicit-origin and xpub-validity
135 // after the v0.11 ordering / multipath / taptree checks. Order matters:
136 // ordering must run first so subsequent checks see canonical indices.
137 //
138 // P0.3 (I-1): the empty-origin-override reject is UNCONDITIONAL and a
139 // DISTINCT error from `MissingExplicitOrigin` — it runs regardless of
140 // `opts` and regardless of canonical-shape status (I-1a), so it is
141 // never swallowed by partial-allowing decode below (I-1b,
142 // fatal-in-partial).
143 crate::validate::validate_no_empty_origin_overrides(&descriptor)?;
144 match crate::validate::validate_explicit_origin_required(&descriptor) {
145 Ok(()) => {}
146 Err(Error::MissingExplicitOrigin { .. }) if opts.allow_unresolved_origin => {
147 // P0.2: partial-allowing decode swallows ONLY this reject.
148 // The caller queries `Descriptor::unresolved_origin_indices()`
149 // on the returned descriptor to learn which `@N` are
150 // unresolved.
151 }
152 Err(e) => return Err(e),
153 }
154 crate::validate::validate_xpub_bytes(&descriptor)?;
155
156 Ok(descriptor)
157}
158
159/// Decode a Descriptor from a complete codex32 md1 string.
160///
161/// Uses the symbol-aligned bit count returned by `unwrap_string` (5 × symbol_count),
162/// which is exact at the codex32 layer with ≤4 bits of trailing zero-padding —
163/// well within the v11 decoder's TLV-rollback tolerance.
164///
165/// F-A2: in-band auto-dispatch per SPEC v0.30 §2.3. The chunked-flag lives in
166/// bit 0 (LSB) of the first 5-bit symbol (`[v3][v2][v1][v0][chunked]` for a
167/// chunk header vs `[divergent][v3][v2][v1][v0]` for a single payload). When
168/// set, the string is a chunk-form md1 and MUST route through the
169/// chunk-reassembly path (a 1-element set) rather than the single-payload
170/// primitive `decode_payload` — mirroring `decode_with_correction`'s
171/// single-string auto-dispatch. The usable single-payload version set {4,8,12}
172/// is all-even ⇒ every currently-valid single-payload string has first-symbol
173/// LSB = 0, so this dispatch never diverts an input that decodes today. No
174/// recursion cycle: `reassemble` → `decode_payload`, never back to here.
175///
176/// Strict (byte-identical to pre-P0 behavior). Delegates to
177/// [`decode_md1_string_with_opts`] with the default (strict) options.
178pub fn decode_md1_string(s: &str) -> Result<Descriptor, Error> {
179 decode_md1_string_with_opts(s, DecodeOpts::default())
180}
181
182/// Decode a Descriptor from a complete codex32 md1 string, honoring
183/// `opts` (P0 partial-decode; see [`DecodeOpts`]). Routes chunk-form
184/// strings through [`crate::chunk::reassemble_with_opts`] and
185/// single-payload strings through [`decode_payload_with_opts`], so
186/// `opts` reaches whichever layer actually performs the origin check.
187pub fn decode_md1_string_with_opts(s: &str, opts: DecodeOpts) -> Result<Descriptor, Error> {
188 let (bytes, symbol_aligned_bit_count) = crate::codex32::unwrap_string(s)?;
189 // The first symbol occupies the top 5 bits of byte 0 (MSB-first packing),
190 // so its LSB (the chunked-flag) is bit 3 of byte 0.
191 let chunked_flag = bytes.first().map(|b| (b >> 3) & 0x01).unwrap_or(0);
192 if chunked_flag == 1 {
193 return crate::chunk::reassemble_with_opts(&[s], opts);
194 }
195 decode_payload_with_opts(&bytes, symbol_aligned_bit_count, opts)
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::encode::encode_payload;
202 use crate::origin_path::{OriginPath, PathComponent, PathDeclPaths};
203 use crate::tlv::TlvSection;
204 use crate::tree::{Body, Node};
205
206 /// SPEC §11 TopLevel check: a wire payload whose root tag is outside the
207 /// BIP-388 allow-list `{Sh, Wsh, Wpkh, Pkh, Tr}` must be rejected with
208 /// `Error::OperatorContextViolation { context: ContextKind::TopLevel }`.
209 /// The encoder has no root-tag gate (only placeholder/multipath/taptree
210 /// validators run), so `encode_payload` of an AndV-rooted descriptor
211 /// succeeds and round-trips through `decode_payload` exposes the gap.
212 #[test]
213 fn decode_rejects_non_canonical_root_tag() {
214 // The TopLevel check fires in `decode_payload` before any downstream
215 // validator runs, so this test reaches the rejection regardless of
216 // whether path_decl would satisfy `validate_explicit_origin_required`
217 // (it does, but the check is short-circuited above). path_decl is
218 // populated here to mirror a realistic descriptor shape.
219 let d = Descriptor {
220 n: 1,
221 path_decl: PathDecl {
222 n: 1,
223 paths: PathDeclPaths::Shared(OriginPath {
224 components: vec![PathComponent {
225 hardened: true,
226 value: 84,
227 }],
228 }),
229 },
230 use_site_path: UseSitePath::standard_multipath(),
231 tree: Node {
232 tag: Tag::AndV,
233 body: Body::Children(vec![
234 Node {
235 tag: Tag::PkK,
236 body: Body::KeyArg { index: 0 },
237 },
238 Node {
239 tag: Tag::PkK,
240 body: Body::KeyArg { index: 0 },
241 },
242 ]),
243 },
244 tlv: TlvSection::new_empty(),
245 };
246 let (bytes, total_bits) = encode_payload(&d).expect("encode AndV-rooted ok");
247 let err = decode_payload(&bytes, total_bits).expect_err("decode must reject");
248 assert!(
249 matches!(
250 err,
251 Error::OperatorContextViolation {
252 tag: Tag::AndV,
253 context: ContextKind::TopLevel,
254 }
255 ),
256 "expected OperatorContextViolation{{TopLevel}}, got {err:?}"
257 );
258 }
259}