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/// Render a codex32 string with optional N-char hyphen grouping for
95/// transcription aid. Per spec §10.2, every 4-5 chars optionally separated by
96/// `-` for human readability. `group_size = 0` returns the input unchanged
97/// (no grouping).
98pub fn render_codex32_grouped(s: &str, group_size: usize) -> String {
99    if group_size == 0 {
100        return s.to_string();
101    }
102    let mut out = String::new();
103    for (i, ch) in s.chars().enumerate() {
104        if i > 0 && i % group_size == 0 {
105            out.push('-');
106        }
107        out.push(ch);
108    }
109    out
110}
111
112/// Encode a Descriptor into a complete codex32 md1 string (HRP + payload + BCH checksum).
113/// Returns the canonical single-string form.
114pub fn encode_md1_string(d: &Descriptor) -> Result<String, Error> {
115    let (bytes, bit_len) = encode_payload(d)?;
116    crate::codex32::wrap_payload(&bytes, bit_len)
117}
118
119#[cfg(test)]
120mod render_tests {
121    use super::*;
122
123    #[test]
124    fn render_groups_at_4() {
125        assert_eq!(render_codex32_grouped("md1qpz9r4cy7", 4), "md1q-pz9r-4cy7");
126    }
127
128    #[test]
129    fn render_zero_group_size_no_grouping() {
130        assert_eq!(render_codex32_grouped("md1qpz9r4cy7", 0), "md1qpz9r4cy7");
131    }
132}
133
134#[cfg(test)]
135mod is_wallet_policy_tests {
136    use super::*;
137    use crate::origin_path::OriginPath;
138    use crate::tag::Tag;
139    use crate::tlv::TlvSection;
140
141    fn wpkh_template_only() -> Descriptor {
142        Descriptor {
143            n: 1,
144            path_decl: PathDecl {
145                n: 1,
146                paths: PathDeclPaths::Shared(OriginPath { components: vec![] }),
147            },
148            use_site_path: UseSitePath::standard_multipath(),
149            tree: Node {
150                tag: Tag::Wpkh,
151                body: Body::KeyArg { index: 0 },
152            },
153            tlv: TlvSection::new_empty(),
154        }
155    }
156
157    #[test]
158    fn is_wallet_policy_returns_false_for_template_only() {
159        // pubkeys = None → not wallet-policy mode.
160        let d = wpkh_template_only();
161        assert!(!d.is_wallet_policy());
162    }
163
164    #[test]
165    fn is_wallet_policy_returns_false_for_empty_pubkeys() {
166        // pubkeys = Some(vec![]) is impossible to encode (encoder rejects)
167        // but the decoder may shape this state in transit. Predicate must
168        // still report "not wallet-policy" so dispatch is presence-driven.
169        let mut d = wpkh_template_only();
170        d.tlv.pubkeys = Some(Vec::new());
171        assert!(!d.is_wallet_policy());
172    }
173
174    #[test]
175    fn is_wallet_policy_returns_true_for_populated_pubkeys() {
176        let mut d = wpkh_template_only();
177        d.tlv.pubkeys = Some(vec![(0u8, [0u8; 65])]);
178        assert!(d.is_wallet_policy());
179    }
180}