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}