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
55/// Encode a [`Descriptor`] into the canonical payload bit stream and return
56/// `(bytes, total_bit_count)`. The bytes are zero-padded; `total_bit_count`
57/// is the exact unpadded length needed for round-trip decoding (see §3.7's
58/// "TLV section ends when codex32 total-length is exhausted" rule).
59///
60/// Per SPEC §6.1, the encoder canonicalizes BIP 388 placeholder
61/// ordering before emitting bits: `@i` first appears in the tree before
62/// `@j` for `j > i`. Canonicalization permutes the tree indices,
63/// divergent path decl, and per-`@N` TLV maps atomically; if `d` is
64/// already canonical it is unchanged.
65pub fn encode_payload(d: &Descriptor) -> Result<(Vec<u8>, usize), Error> {
66    let mut d_canonical = d.clone();
67    crate::canonicalize::canonicalize_placeholder_indices(&mut d_canonical)?;
68    let d = &d_canonical;
69    crate::validate::validate_placeholder_usage(&d.tree, d.n)?;
70    if let Some(overrides) = &d.tlv.use_site_path_overrides {
71        crate::validate::validate_multipath_consistency(&d.use_site_path, overrides)?;
72    }
73    if matches!(d.tree.tag, crate::tag::Tag::Tr) {
74        if let Body::Tr { tree: Some(t), .. } = &d.tree.body {
75            crate::validate::validate_tap_script_tree(t)?;
76        }
77    }
78
79    let mut w = BitWriter::new();
80    let header = Header {
81        version: Header::WF_REDESIGN_VERSION,
82        divergent_paths: matches!(d.path_decl.paths, PathDeclPaths::Divergent(_)),
83    };
84    header.write(&mut w);
85    d.path_decl.write(&mut w)?;
86    d.use_site_path.write(&mut w)?;
87    let kiw = d.key_index_width();
88    write_node(&mut w, &d.tree, kiw)?;
89    d.tlv.write(&mut w, kiw)?;
90    let total_bits = w.bit_len();
91    Ok((w.into_bytes(), total_bits))
92}
93
94/// True for any character treated as a display separator on intake: ALL Unicode
95/// whitespace plus `-` and `,`. SPEC §3.2 (mstring display-grouping). None of
96/// these appear in the codex32 alphabet (`qpzry9x8gf2tvdw0s3jn54khce6mua7l`) or
97/// the `ms`/`mk`/`md`/`1` structural chars (SPEC §4), so stripping is unambiguous.
98pub fn is_display_separator(c: char) -> bool {
99    c.is_whitespace() || c == '-' || c == ','
100}
101
102/// Insert `separator` after every `group_size` characters (SPEC §3.1).
103/// `group_size == 0` returns the input unchanged. Single line; ASCII-safe.
104pub fn render_grouped(s: &str, group_size: usize, separator: char) -> String {
105    if group_size == 0 {
106        return s.to_string();
107    }
108    let mut out = String::with_capacity(s.len() + s.len() / group_size);
109    for (i, ch) in s.chars().enumerate() {
110        if i > 0 && i % group_size == 0 {
111            out.push(separator);
112        }
113        out.push(ch);
114    }
115    out
116}
117
118/// Strip every display separator (SPEC §3.2) — used on intake before decode.
119/// Idempotent; strips ONLY separators (other chars pass through, so a malformed
120/// card is never silently "cleaned" into validity).
121pub fn strip_display_separators(s: &str) -> String {
122    s.chars().filter(|&c| !is_display_separator(c)).collect()
123}
124
125/// Render a codex32 string with optional N-char HYPHEN grouping for
126/// transcription aid (spec §10.2). `group_size = 0` returns the input unchanged.
127/// Back-compat wrapper over `render_grouped` (hyphen separator). Retained as
128/// public API (documented in the technical manual); new callers use
129/// `render_grouped` with an explicit separator.
130pub fn render_codex32_grouped(s: &str, group_size: usize) -> String {
131    render_grouped(s, group_size, '-')
132}
133
134/// Encode a Descriptor into a complete codex32 md1 string (HRP + payload + BCH checksum).
135/// Returns the canonical single-string form.
136pub fn encode_md1_string(d: &Descriptor) -> Result<String, Error> {
137    let (bytes, bit_len) = encode_payload(d)?;
138    crate::codex32::wrap_payload(&bytes, bit_len)
139}
140
141#[cfg(test)]
142mod render_tests {
143    use super::*;
144
145    #[test]
146    fn render_groups_at_4() {
147        assert_eq!(render_codex32_grouped("md1qpz9r4cy7", 4), "md1q-pz9r-4cy7");
148    }
149
150    #[test]
151    fn render_zero_group_size_no_grouping() {
152        assert_eq!(render_codex32_grouped("md1qpz9r4cy7", 0), "md1qpz9r4cy7");
153    }
154
155    #[test]
156    fn render_grouped_separators_and_unbroken() {
157        assert_eq!(render_grouped("abcdefghij", 5, ' '), "abcde fghij");
158        assert_eq!(render_grouped("abcdefghij", 5, '-'), "abcde-fghij");
159        assert_eq!(render_grouped("abcdefghij", 5, ','), "abcde,fghij");
160        assert_eq!(render_grouped("abcdefghij", 0, ' '), "abcdefghij");
161        assert_eq!(render_grouped("abcde", 5, ' '), "abcde");
162        assert_eq!(render_grouped("abcdefg", 3, '-'), "abc-def-g");
163        assert_eq!(render_grouped("", 5, ' '), "");
164    }
165
166    #[test]
167    fn render_codex32_grouped_still_hyphens() {
168        // back-compat wrapper: unchanged behavior
169        assert_eq!(render_codex32_grouped("abcdefghij", 5), "abcde-fghij");
170        assert_eq!(render_codex32_grouped("abcde", 0), "abcde");
171    }
172
173    #[test]
174    fn strip_display_separators_whitespace_hyphen_comma() {
175        assert_eq!(strip_display_separators("abcde fghij"), "abcdefghij");
176        assert_eq!(strip_display_separators("ab-cd,ef gh"), "abcdefgh");
177        assert_eq!(strip_display_separators("ab\tcd\r\nef"), "abcdef");
178        assert_eq!(strip_display_separators("ms1qpzry9x8"), "ms1qpzry9x8");
179        let once = strip_display_separators("a b-c,d");
180        assert_eq!(strip_display_separators(&once), once);
181    }
182}
183
184#[cfg(test)]
185mod is_wallet_policy_tests {
186    use super::*;
187    use crate::origin_path::OriginPath;
188    use crate::tag::Tag;
189    use crate::tlv::TlvSection;
190
191    fn wpkh_template_only() -> Descriptor {
192        Descriptor {
193            n: 1,
194            path_decl: PathDecl {
195                n: 1,
196                paths: PathDeclPaths::Shared(OriginPath { components: vec![] }),
197            },
198            use_site_path: UseSitePath::standard_multipath(),
199            tree: Node {
200                tag: Tag::Wpkh,
201                body: Body::KeyArg { index: 0 },
202            },
203            tlv: TlvSection::new_empty(),
204        }
205    }
206
207    #[test]
208    fn is_wallet_policy_returns_false_for_template_only() {
209        // pubkeys = None → not wallet-policy mode.
210        let d = wpkh_template_only();
211        assert!(!d.is_wallet_policy());
212    }
213
214    #[test]
215    fn is_wallet_policy_returns_false_for_empty_pubkeys() {
216        // pubkeys = Some(vec![]) is impossible to encode (encoder rejects)
217        // but the decoder may shape this state in transit. Predicate must
218        // still report "not wallet-policy" so dispatch is presence-driven.
219        let mut d = wpkh_template_only();
220        d.tlv.pubkeys = Some(Vec::new());
221        assert!(!d.is_wallet_policy());
222    }
223
224    #[test]
225    fn is_wallet_policy_returns_true_for_populated_pubkeys() {
226        let mut d = wpkh_template_only();
227        d.tlv.pubkeys = Some(vec![(0u8, [0u8; 65])]);
228        assert!(d.is_wallet_policy());
229    }
230}