Skip to main content

bitcoin_uri_composer/
lib.rs

1use bitcoin::{
2    Address, Amount, Denomination, Network,
3    address::{NetworkChecked, NetworkUnchecked, NetworkValidation},
4};
5use std::borrow::Cow;
6use std::collections::{HashMap, HashSet};
7use urlencoding::{decode, encode};
8
9#[derive(Debug, PartialEq, Eq, Clone)]
10pub enum Bip321Errors<'a> {
11    DuplicateParam(&'a str),
12    IncorrectSchema,
13    InvalidAddress(&'a str),
14    InvalidAmount,
15    NoOnePaymentWasFound,
16    InvalidEncoding,
17    InvalidRequiredPayment,
18}
19
20pub trait Bip321ExtraHandle<'a>
21where
22    Self: Default,
23{
24    fn handle_param(
25        &mut self,
26        key: &'a str,
27        value: Vec<Cow<'a, str>>,
28    ) -> Result<(), Bip321Errors<'a>>;
29
30    fn validate(&self, _network: Network) -> Result<(), Bip321Errors<'a>> {
31        Ok(())
32    }
33
34    fn is_empty(&self) -> bool;
35
36    fn is_supported_key(&self, key: &str) -> bool;
37}
38
39#[derive(Debug, PartialEq, Eq, Clone)]
40pub struct PopConfig<'a> {
41    pop: Cow<'a, str>,
42    pub required: bool,
43}
44
45impl<'a> PopConfig<'a> {
46    pub fn finalize_uri(
47        &self,
48        source_key: &str,
49        payment_data_hex: &str,
50    ) -> Result<String, Bip321Errors<'a>> {
51        if !payment_data_hex.chars().all(|c| c.is_ascii_hexdigit()) {
52            return Err(Bip321Errors::InvalidEncoding);
53        };
54
55        let append_to_pop = format!("{}{}={}", self.pop, source_key, payment_data_hex);
56
57        Ok(append_to_pop)
58    }
59}
60
61#[derive(Debug, PartialEq, Eq, Clone)]
62pub struct Bip321<'a, T, NetVal = NetworkUnchecked>
63where
64    T: Bip321ExtraHandle<'a>,
65    NetVal: NetworkValidation,
66{
67    pub address: Option<Address<NetVal>>,
68    pub amount: Option<Amount>,
69    pub label: Option<Cow<'a, str>>,
70    pub message: Option<Cow<'a, str>>,
71    pub pop: Option<PopConfig<'a>>,
72    pub extras: Option<T>,
73}
74
75impl<'a, T: Bip321ExtraHandle<'a>> Bip321<'a, T> {
76    pub fn parse_url(s: &'a str) -> Result<Self, Bip321Errors<'a>> {
77        let uri = s.trim();
78
79        if uri.len() < 8 || !uri[..8].eq_ignore_ascii_case("bitcoin:") {
80            return Err(Bip321Errors::IncorrectSchema);
81        }
82
83        let body = &uri[8..];
84        let (address_str, query_str) = match body.find("?") {
85            Some(pos) => (&body[..pos], &body[pos + 1..]),
86            None => (&body[..], ""),
87        };
88
89        let mut seens: HashSet<&'a str> = HashSet::new();
90        let mut extra_params: HashMap<&'a str, Vec<Cow<'a, str>>> = HashMap::new();
91        let mut result: Bip321<T, NetworkUnchecked> = Bip321::default();
92
93        if !address_str.is_empty() {
94            let addr = address_str
95                .parse::<Address<NetworkUnchecked>>()
96                .map_err(|_| Bip321Errors::InvalidAddress(address_str))?;
97
98            result.address = Some(addr);
99        }
100
101        if !query_str.is_empty() {
102            for param in query_str.split("&") {
103                let (key, value) = param.split_once("=").unwrap_or((param, ""));
104
105                let is_pop_related =
106                    key.eq_ignore_ascii_case("pop") || key.eq_ignore_ascii_case("req-pop");
107
108                if is_pop_related {
109                    if !seens.insert("pop") {
110                        return Err(Bip321Errors::DuplicateParam(key));
111                    }
112                } else if matches!(key, "amount" | "label" | "message") {
113                    if !seens.insert(key) {
114                        return Err(Bip321Errors::DuplicateParam(key));
115                    }
116                }
117
118                match key {
119                    "amount" => {
120                        if value.contains(",") {
121                            return Err(Bip321Errors::InvalidAmount);
122                        }
123                        let amt = Amount::from_str_in(value, Denomination::Bitcoin)
124                            .map_err(|_| Bip321Errors::InvalidAmount)?;
125                        result.amount = Some(amt);
126                    }
127                    "label" => {
128                        result.label =
129                            Some(decode(value).map_err(|_| Bip321Errors::InvalidEncoding)?);
130                    }
131                    "message" => {
132                        result.message =
133                            Some(decode(value).map_err(|_| Bip321Errors::InvalidEncoding)?);
134                    }
135                    "pop" | "req-pop" => {
136                        let forbidden_schemes = ["http", "https", "file", "javascript", "mailto"];
137                        let decoded_val = decode(value)
138                            .map(|s| s)
139                            .map_err(|_| Bip321Errors::InvalidEncoding)?;
140                        let value_lower = decoded_val.to_lowercase();
141
142                        if forbidden_schemes
143                            .iter()
144                            .any(|&s| value_lower.starts_with(s))
145                        {
146                            return Err(Bip321Errors::IncorrectSchema);
147                        } else {
148                            result.pop = if key != "pop" {
149                                Some(PopConfig {
150                                    pop: decoded_val,
151                                    required: true,
152                                })
153                            } else {
154                                Some(PopConfig {
155                                    pop: decoded_val,
156                                    required: false,
157                                })
158                            };
159                        }
160                    }
161                    _ => {
162                        let decoded_val = decode(value)
163                            .map(|s| s)
164                            .map_err(|_| Bip321Errors::InvalidEncoding)?;
165                        extra_params
166                            .entry(key)
167                            .or_insert(Vec::new())
168                            .push(decoded_val);
169                    }
170                }
171            }
172        }
173
174        for (key, values) in extra_params {
175            let ext = result.extras.get_or_insert_with(T::default);
176            if key.starts_with("req-") {
177                let stripped = &key[4..];
178                if !ext.is_supported_key(stripped) {
179                    return Err(Bip321Errors::InvalidRequiredPayment);
180                }
181                ext.handle_param(stripped, values)?;
182            } else {
183                ext.handle_param(key, values)?
184            }
185        }
186
187        if let Some(ext) = result.extras.as_ref() {
188            if ext.is_empty() {
189                result.extras = None;
190            }
191        }
192
193        if result.address.is_none() && result.extras.is_none() {
194            return Err(Bip321Errors::NoOnePaymentWasFound);
195        }
196
197        Ok(result)
198    }
199
200    pub fn build(&self) -> String {
201        let mut uri = String::from("bitcoin:");
202
203        if let Some(ref addr) = self.address {
204            let address = addr.clone().assume_checked().to_string();
205            uri.push_str(&format!("{}", address));
206        }
207
208        let mut params: Vec<String> = Vec::new();
209
210        if let Some(amount) = self.amount {
211            params.push(format!("amount={}", amount.to_btc()));
212        }
213
214        if let Some(label) = &self.label {
215            params.push(format!("label={}", encode(label)));
216        }
217
218        if let Some(ref message) = self.message {
219            params.push(format!("message={}", encode(message)));
220        }
221
222        if let Some(ref pop_conf) = self.pop {
223            if pop_conf.required {
224                params.push(format!("req-pop={}", encode(&pop_conf.pop)));
225            } else {
226                params.push(format!("pop={}", encode(&pop_conf.pop)));
227            }
228        }
229
230        if !params.is_empty() {
231            uri.push('?');
232            uri.push_str(&params.join("&"));
233        }
234
235        uri
236    }
237}
238impl<'a, T: Bip321ExtraHandle<'a>> Bip321<'a, T, NetworkUnchecked> {
239    pub fn into_checked(
240        self,
241        network: Network,
242    ) -> Result<Bip321<'a, T, NetworkChecked>, Bip321Errors<'a>> {
243        let checked_addr = match self.address {
244            Some(addr) => {
245                let checked = addr
246                    .require_network(network)
247                    .map_err(|_| Bip321Errors::InvalidAddress("Wrong Network"))?;
248                Some(checked)
249            }
250            None => None,
251        };
252
253        if let Some(ext) = self.extras.as_ref() {
254            ext.validate(network)?;
255        }
256
257        Ok(Bip321 {
258            address: checked_addr,
259            amount: self.amount,
260            label: self.label,
261            message: self.message,
262            pop: self.pop,
263            extras: self.extras,
264        })
265    }
266}
267
268impl<'a, T: Bip321ExtraHandle<'a>> Default for Bip321<'a, T, NetworkUnchecked> {
269    fn default() -> Self {
270        Bip321 {
271            address: None,
272            amount: None,
273            label: None,
274            message: None,
275            pop: None,
276            extras: None,
277        }
278    }
279}
280
281#[derive(Debug, PartialEq, Eq, Clone, Default)]
282pub struct ExtraExample {
283    pub pj: Vec<String>,
284    pub sp: Vec<String>,
285    pub lightning: Vec<String>,
286}
287
288impl<'a> Bip321ExtraHandle<'a> for ExtraExample {
289    fn handle_param(
290        &mut self,
291        key: &'a str,
292        values: Vec<Cow<'a, str>>,
293    ) -> Result<(), Bip321Errors<'a>> {
294        match key {
295            "pj" => {
296                for val in values {
297                    self.pj.push(val.to_string());
298                }
299                Ok(())
300            }
301            "lightning" => {
302                for val in values {
303                    self.lightning.push(val.to_string());
304                }
305                Ok(())
306            }
307            "sp" => {
308                for val in values {
309                    self.sp.push(val.to_string());
310                }
311                Ok(())
312            }
313            _ => Ok(()),
314        }
315    }
316
317    fn is_empty(&self) -> bool {
318        self.pj.is_empty() && self.lightning.is_empty() && self.sp.is_empty()
319    }
320
321    fn is_supported_key(&self, key: &str) -> bool {
322        matches!(key, "pj" | "lightning" | "sp")
323    }
324}