Skip to main content

silent_payments_descriptor/
descriptor.rs

1//! SpDescriptor: BIP 392 descriptor struct with BIP 393 annotations.
2//!
3//! Wraps an [`SpAddress`] and optional annotations (birthday height, gap limit,
4//! max labels). Implements [`std::fmt::Display`] to produce a checksummed descriptor string
5//! and [`FromStr`] to parse one.
6
7use std::fmt;
8use std::str::FromStr;
9
10use silent_payments_core::address::SpAddress;
11
12use crate::checksum::descriptor_checksum;
13use crate::error::DescriptorError;
14
15/// A BIP 392 Silent Payments descriptor.
16///
17/// Contains an [`SpAddress`] (scan + spend public keys) plus optional BIP 393
18/// annotations for wallet configuration.
19///
20/// # Construction
21///
22/// ```
23/// use silent_payments_core::address::SpAddress;
24/// use silent_payments_core::keys::{ScanPublicKey, SpendPublicKey};
25/// use bitcoin::Network;
26/// use bitcoin::secp256k1::{Secp256k1, SecretKey};
27/// use silent_payments_descriptor::SpDescriptor;
28///
29/// let secp = Secp256k1::new();
30/// let sk = SecretKey::from_slice(&[0x01; 32]).unwrap();
31/// let pk = sk.public_key(&secp);
32/// let addr = SpAddress::new(ScanPublicKey::from(pk), SpendPublicKey::from(pk), Network::Bitcoin);
33/// let desc = SpDescriptor::from(addr).with_birthday_height(850000);
34/// assert_eq!(desc.birthday_height(), Some(850000));
35/// ```
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SpDescriptor {
38    address: SpAddress,
39    birthday_height: Option<u32>,
40    gap_limit: Option<u32>,
41    max_labels: Option<u32>,
42}
43
44impl SpDescriptor {
45    /// Returns a reference to the wrapped [`SpAddress`].
46    pub fn address(&self) -> &SpAddress {
47        &self.address
48    }
49
50    /// Returns the wallet birthday height annotation, if set.
51    pub fn birthday_height(&self) -> Option<u32> {
52        self.birthday_height
53    }
54
55    /// Returns the gap limit annotation, if set.
56    pub fn gap_limit(&self) -> Option<u32> {
57        self.gap_limit
58    }
59
60    /// Returns the max labels annotation, if set.
61    pub fn max_labels(&self) -> Option<u32> {
62        self.max_labels
63    }
64
65    /// Returns a new descriptor with the birthday height annotation set.
66    ///
67    /// Does not mutate `self`; returns a new value (immutable builder pattern).
68    pub fn with_birthday_height(self, height: u32) -> Self {
69        Self {
70            birthday_height: Some(height),
71            ..self
72        }
73    }
74
75    /// Returns a new descriptor with the gap limit annotation set.
76    ///
77    /// Does not mutate `self`; returns a new value (immutable builder pattern).
78    pub fn with_gap_limit(self, limit: u32) -> Self {
79        Self {
80            gap_limit: Some(limit),
81            ..self
82        }
83    }
84
85    /// Returns a new descriptor with the max labels annotation set.
86    ///
87    /// Does not mutate `self`; returns a new value (immutable builder pattern).
88    pub fn with_max_labels(self, max: u32) -> Self {
89        Self {
90            max_labels: Some(max),
91            ..self
92        }
93    }
94
95    /// Construct directly from parsed components. Used by the parser.
96    pub(crate) fn new_parsed(
97        address: SpAddress,
98        birthday_height: Option<u32>,
99        gap_limit: Option<u32>,
100        max_labels: Option<u32>,
101    ) -> Self {
102        Self {
103            address,
104            birthday_height,
105            gap_limit,
106            max_labels,
107        }
108    }
109}
110
111impl From<SpAddress> for SpDescriptor {
112    fn from(address: SpAddress) -> Self {
113        Self {
114            address,
115            birthday_height: None,
116            gap_limit: None,
117            max_labels: None,
118        }
119    }
120}
121
122impl fmt::Display for SpDescriptor {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        // Build the sp() expression body with hex-encoded compressed pubkeys.
125        let scan_hex = self.address.scan_pubkey().as_inner();
126        let spend_hex = self.address.spend_pubkey().as_inner();
127
128        let mut body = format!("sp({scan_hex},{spend_hex})");
129
130        // Append BIP 393 annotations (outside parens, before checksum).
131        let annotations =
132            build_annotation_string(self.birthday_height, self.gap_limit, self.max_labels);
133        if !annotations.is_empty() {
134            body.push('?');
135            body.push_str(&annotations);
136        }
137
138        // Compute and append BIP 380 checksum.
139        let checksum = descriptor_checksum(&body)
140            .expect("descriptor body should only contain INPUT_CHARSET characters");
141
142        write!(f, "{body}#{checksum}")
143    }
144}
145
146/// Build the annotation query string from optional fields.
147///
148/// Only includes keys with `Some` values. Uses `&` separator between pairs.
149fn build_annotation_string(
150    birthday_height: Option<u32>,
151    gap_limit: Option<u32>,
152    max_labels: Option<u32>,
153) -> String {
154    let mut parts = Vec::new();
155    if let Some(bh) = birthday_height {
156        parts.push(format!("bh={bh}"));
157    }
158    if let Some(gl) = gap_limit {
159        parts.push(format!("gl={gl}"));
160    }
161    if let Some(ml) = max_labels {
162        parts.push(format!("ml={ml}"));
163    }
164    parts.join("&")
165}
166
167impl FromStr for SpDescriptor {
168    type Err = DescriptorError;
169
170    fn from_str(s: &str) -> Result<Self, Self::Err> {
171        crate::parser::parse_descriptor(s)
172    }
173}