Skip to main content

md_codec/
encode.rs

1//! Top-level encoder per spec §13.3.
2
3use crate::bitstream::BitWriter;
4use crate::error::Error;
5use crate::header::Header;
6use crate::origin_path::{PathDecl, PathDeclPaths};
7use crate::tlv::TlvSection;
8use crate::tree::{Body, Node, write_node};
9use crate::use_site_path::UseSitePath;
10
11/// Top-level descriptor parsed/built from a v0.30 wire payload.
12///
13/// Each field corresponds to a spec section: Header (§3.2), origin
14/// `PathDecl` (§3.3), use-site `UseSitePath` (§3.4), descriptor `tree`
15/// (§3.5–3.6), and trailing `tlv` section (§3.7).
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Descriptor {
18    /// Number of placeholders (1-indexed key universe size).
19    pub n: u8,
20    /// Origin path declaration (single or per-`@N` divergent).
21    pub path_decl: PathDecl,
22    /// Use-site (post-key) path applied to every key by default.
23    pub use_site_path: UseSitePath,
24    /// Descriptor tree root node.
25    pub tree: Node,
26    /// Trailing TLV section (overrides, fingerprints, etc.).
27    pub tlv: TlvSection,
28}
29
30impl Descriptor {
31    /// Bit width for placeholder-index encoding: ⌈log₂(n)⌉ per SPEC v0.30 §7.
32    ///
33    /// Index range is `0..n`. The NUMS H-point is signalled by an explicit
34    /// `is_nums` bit on `Body::Tr` (SPEC §7), not by a reserved sentinel.
35    /// MUST stay in lockstep with `decode::decode_payload`'s independent
36    /// computation; a stale formula would silently desync the bitstream.
37    pub fn key_index_width(&self) -> u8 {
38        // ⌈log₂(n)⌉ for n ≥ 2; clamp to 0 at n ∈ {0, 1}.
39        // Identity: ⌈log₂(n)⌉ = bit_length(n-1) for n ≥ 2.
40        (32 - (self.n as u32).saturating_sub(1).leading_zeros()) as u8
41    }
42
43    /// Returns `true` iff this descriptor is in **wallet-policy mode** per
44    /// SPEC §3.3: the `Pubkeys` TLV is present *and* contains at least one
45    /// entry. Template-only mode (no `Pubkeys` TLV at all, or `Pubkeys =
46    /// Some(vec![])` after sparse-decode) returns `false`.
47    ///
48    /// The check is a post-TLV-decode predicate; mode dispatch never reads
49    /// a header bit.
50    pub fn is_wallet_policy(&self) -> bool {
51        matches!(&self.tlv.pubkeys, Some(v) if !v.is_empty())
52    }
53
54    /// Return the deterministic pre-chunking **canonical packed payload** of
55    /// this descriptor as a bit-precise `(bytes, total_bits)` pair, for a
56    /// downstream consumer that needs the raw, round-trippable byte payload
57    /// (without the codex32/`md1` string wrapper or chunk framing).
58    ///
59    /// This delegates to [`encode_payload`], which canonicalizes BIP 388
60    /// placeholder ordering internally (SPEC §6.1) before emitting bits — so
61    /// the output is **canonical for any valid input** descriptor (two inputs
62    /// that differ only in non-canonical placeholder numbering produce
63    /// byte-identical payloads).
64    ///
65    /// The payload is **bit-aligned**: the returned `bytes` are zero-padded to
66    /// a byte boundary, and `total_bits` is the exact unpadded bit count. The
67    /// final byte may carry up to 7 trailing zero-pad bits, so `total_bits` is
68    /// **load-bearing** — a consumer MUST round-trip with this exact bit count,
69    /// not `bytes.len() * 8`. Feed both values back to
70    /// [`Descriptor::from_canonical_payload_bytes`] to recover the descriptor.
71    pub fn canonical_payload_bytes(&self) -> Result<(Vec<u8>, usize), Error> {
72        encode_payload(self)
73    }
74
75    /// Reconstruct a [`Descriptor`] from a bit-precise canonical packed
76    /// payload previously produced by [`Descriptor::canonical_payload_bytes`].
77    ///
78    /// `bytes` may be zero-padded to a byte boundary; `total_bits` is the
79    /// exact payload bit count (the same value returned alongside the bytes).
80    /// Delegates to [`decode_payload`](crate::decode::decode_payload).
81    pub fn from_canonical_payload_bytes(
82        bytes: &[u8],
83        total_bits: usize,
84    ) -> Result<Descriptor, Error> {
85        crate::decode::decode_payload(bytes, total_bits)
86    }
87}
88
89/// Encode a [`Descriptor`] into the canonical payload bit stream and return
90/// `(bytes, total_bit_count)`. The bytes are zero-padded; `total_bit_count`
91/// is the exact unpadded length needed for round-trip decoding (see §3.7's
92/// "TLV section ends when codex32 total-length is exhausted" rule).
93///
94/// Per SPEC §6.1, the encoder canonicalizes BIP 388 placeholder
95/// ordering before emitting bits: `@i` first appears in the tree before
96/// `@j` for `j > i`. Canonicalization permutes the tree indices,
97/// divergent path decl, and per-`@N` TLV maps atomically; if `d` is
98/// already canonical it is unchanged.
99pub fn encode_payload(d: &Descriptor) -> Result<(Vec<u8>, usize), Error> {
100    let mut d_canonical = d.clone();
101    crate::canonicalize::canonicalize_placeholder_indices(&mut d_canonical)?;
102    let d = &d_canonical;
103    crate::validate::validate_placeholder_usage(&d.tree, d.n)?;
104    if let Some(overrides) = &d.tlv.use_site_path_overrides {
105        crate::validate::validate_multipath_consistency(&d.use_site_path, overrides)?;
106    }
107    if matches!(d.tree.tag, crate::tag::Tag::Tr) {
108        if let Body::Tr { tree: Some(t), .. } = &d.tree.body {
109            crate::validate::validate_tap_script_tree(t)?;
110        }
111    }
112
113    let mut w = BitWriter::new();
114    let header = Header {
115        version: Header::WF_REDESIGN_VERSION,
116        divergent_paths: matches!(d.path_decl.paths, PathDeclPaths::Divergent(_)),
117    };
118    header.write(&mut w);
119    d.path_decl.write(&mut w)?;
120    d.use_site_path.write(&mut w)?;
121    let kiw = d.key_index_width();
122    write_node(&mut w, &d.tree, kiw)?;
123    d.tlv.write(&mut w, kiw)?;
124    let total_bits = w.bit_len();
125    Ok((w.into_bytes(), total_bits))
126}
127
128/// True for any character treated as a display separator on intake: ALL Unicode
129/// whitespace plus `-` and `,`. SPEC §3.2 (mstring display-grouping). None of
130/// these appear in the codex32 alphabet (`qpzry9x8gf2tvdw0s3jn54khce6mua7l`) or
131/// the `ms`/`mk`/`md`/`1` structural chars (SPEC §4), so stripping is unambiguous.
132pub fn is_display_separator(c: char) -> bool {
133    c.is_whitespace() || c == '-' || c == ','
134}
135
136/// Insert `separator` after every `group_size` characters (SPEC §3.1).
137/// `group_size == 0` returns the input unchanged. Single line; ASCII-safe.
138pub fn render_grouped(s: &str, group_size: usize, separator: char) -> String {
139    if group_size == 0 {
140        return s.to_string();
141    }
142    let mut out = String::with_capacity(s.len() + s.len() / group_size);
143    for (i, ch) in s.chars().enumerate() {
144        if i > 0 && i % group_size == 0 {
145            out.push(separator);
146        }
147        out.push(ch);
148    }
149    out
150}
151
152/// Strip every display separator (SPEC §3.2) — used on intake before decode.
153/// Idempotent; strips ONLY separators (other chars pass through, so a malformed
154/// card is never silently "cleaned" into validity).
155pub fn strip_display_separators(s: &str) -> String {
156    s.chars().filter(|&c| !is_display_separator(c)).collect()
157}
158
159/// Render a codex32 string with optional N-char HYPHEN grouping for
160/// transcription aid (spec §10.2). `group_size = 0` returns the input unchanged.
161/// Back-compat wrapper over `render_grouped` (hyphen separator). Retained as
162/// public API (documented in the technical manual); new callers use
163/// `render_grouped` with an explicit separator.
164pub fn render_codex32_grouped(s: &str, group_size: usize) -> String {
165    render_grouped(s, group_size, '-')
166}
167
168/// Encode a Descriptor into a complete codex32 md1 string (HRP + payload + BCH checksum).
169/// Returns the canonical single-string form.
170pub fn encode_md1_string(d: &Descriptor) -> Result<String, Error> {
171    let (bytes, bit_len) = encode_payload(d)?;
172    crate::codex32::wrap_payload(&bytes, bit_len)
173}
174
175#[cfg(test)]
176mod render_tests {
177    use super::*;
178
179    #[test]
180    fn render_groups_at_4() {
181        assert_eq!(render_codex32_grouped("md1qpz9r4cy7", 4), "md1q-pz9r-4cy7");
182    }
183
184    #[test]
185    fn render_zero_group_size_no_grouping() {
186        assert_eq!(render_codex32_grouped("md1qpz9r4cy7", 0), "md1qpz9r4cy7");
187    }
188
189    #[test]
190    fn render_grouped_separators_and_unbroken() {
191        assert_eq!(render_grouped("abcdefghij", 5, ' '), "abcde fghij");
192        assert_eq!(render_grouped("abcdefghij", 5, '-'), "abcde-fghij");
193        assert_eq!(render_grouped("abcdefghij", 5, ','), "abcde,fghij");
194        assert_eq!(render_grouped("abcdefghij", 0, ' '), "abcdefghij");
195        assert_eq!(render_grouped("abcde", 5, ' '), "abcde");
196        assert_eq!(render_grouped("abcdefg", 3, '-'), "abc-def-g");
197        assert_eq!(render_grouped("", 5, ' '), "");
198    }
199
200    #[test]
201    fn render_codex32_grouped_still_hyphens() {
202        // back-compat wrapper: unchanged behavior
203        assert_eq!(render_codex32_grouped("abcdefghij", 5), "abcde-fghij");
204        assert_eq!(render_codex32_grouped("abcde", 0), "abcde");
205    }
206
207    #[test]
208    fn strip_display_separators_whitespace_hyphen_comma() {
209        assert_eq!(strip_display_separators("abcde fghij"), "abcdefghij");
210        assert_eq!(strip_display_separators("ab-cd,ef gh"), "abcdefgh");
211        assert_eq!(strip_display_separators("ab\tcd\r\nef"), "abcdef");
212        assert_eq!(strip_display_separators("ms1qpzry9x8"), "ms1qpzry9x8");
213        let once = strip_display_separators("a b-c,d");
214        assert_eq!(strip_display_separators(&once), once);
215    }
216}
217
218#[cfg(test)]
219mod is_wallet_policy_tests {
220    use super::*;
221    use crate::origin_path::OriginPath;
222    use crate::tag::Tag;
223    use crate::tlv::TlvSection;
224
225    fn wpkh_template_only() -> Descriptor {
226        Descriptor {
227            n: 1,
228            path_decl: PathDecl {
229                n: 1,
230                paths: PathDeclPaths::Shared(OriginPath { components: vec![] }),
231            },
232            use_site_path: UseSitePath::standard_multipath(),
233            tree: Node {
234                tag: Tag::Wpkh,
235                body: Body::KeyArg { index: 0 },
236            },
237            tlv: TlvSection::new_empty(),
238        }
239    }
240
241    #[test]
242    fn is_wallet_policy_returns_false_for_template_only() {
243        // pubkeys = None → not wallet-policy mode.
244        let d = wpkh_template_only();
245        assert!(!d.is_wallet_policy());
246    }
247
248    #[test]
249    fn is_wallet_policy_returns_false_for_empty_pubkeys() {
250        // pubkeys = Some(vec![]) is impossible to encode (encoder rejects)
251        // but the decoder may shape this state in transit. Predicate must
252        // still report "not wallet-policy" so dispatch is presence-driven.
253        let mut d = wpkh_template_only();
254        d.tlv.pubkeys = Some(Vec::new());
255        assert!(!d.is_wallet_policy());
256    }
257
258    #[test]
259    fn is_wallet_policy_returns_true_for_populated_pubkeys() {
260        let mut d = wpkh_template_only();
261        d.tlv.pubkeys = Some(vec![(0u8, [0u8; 65])]);
262        assert!(d.is_wallet_policy());
263    }
264}