switchgear_service_api/
lnurl.rs

1use crate::offer::{OfferMetadataIdentifier, OfferMetadataImage, OfferMetadataSparse};
2use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
3use base64::Engine;
4use serde::de::{Error, SeqAccess, Visitor};
5use serde::ser::SerializeSeq;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use std::fmt;
8use url::Url;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct LnUrlOffer {
13    pub callback: Url,
14    pub max_sendable: u64,
15    pub min_sendable: u64,
16    pub tag: LnUrlOfferTag,
17    pub metadata: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub comment_allowed: Option<u32>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct LnUrlOfferMetadata(pub OfferMetadataSparse);
24
25impl Serialize for LnUrlOfferMetadata {
26    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
27    where
28        S: Serializer,
29    {
30        let mut len = 1;
31        if self.0.long_text.is_some() {
32            len += 1;
33        }
34
35        if self.0.image.is_some() {
36            len += 1;
37        }
38
39        if self.0.identifier.is_some() {
40            len += 1;
41        }
42
43        let mut seq = serializer.serialize_seq(Some(len))?;
44
45        let text = (LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT, self.0.text.as_str());
46        seq.serialize_element(&text)?;
47
48        if let Some(long_text) = &self.0.long_text {
49            let long_text = (LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_LONG, long_text);
50            seq.serialize_element(&long_text)?;
51        }
52
53        if let Some(image) = &self.0.image {
54            let image = match image {
55                OfferMetadataImage::Png(image) => {
56                    let image = BASE64_STANDARD.encode(image);
57                    (LNURL_OFFER_METADATA_ENTRY_TYPE_PNG_IMAGE, image)
58                }
59                OfferMetadataImage::Jpeg(image) => {
60                    let image = BASE64_STANDARD.encode(image);
61                    (LNURL_OFFER_METADATA_ENTRY_TYPE_JPEG_IMAGE, image)
62                }
63            };
64            seq.serialize_element(&image)?;
65        }
66
67        if let Some(identifier) = &self.0.identifier {
68            let identifier = match identifier {
69                OfferMetadataIdentifier::Text(identifier) => (
70                    LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_IDENTIFIER,
71                    identifier.email(),
72                ),
73                OfferMetadataIdentifier::Email(identifier) => (
74                    LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_EMAIL,
75                    identifier.email(),
76                ),
77            };
78            seq.serialize_element(&identifier)?;
79        }
80
81        seq.end()
82    }
83}
84
85impl<'de> Deserialize<'de> for LnUrlOfferMetadata {
86    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
87    where
88        D: Deserializer<'de>,
89    {
90        struct LnUrlOfferMetadataVisitor;
91
92        impl<'de> Visitor<'de> for LnUrlOfferMetadataVisitor {
93            type Value = LnUrlOfferMetadata;
94
95            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
96                formatter.write_str("an array of [type, value] tuples representing metadata")
97            }
98
99            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
100            where
101                A: SeqAccess<'de>,
102            {
103                let mut text = None;
104                let mut long_text = None;
105                let mut image = None;
106                let mut identifier = None;
107
108                while let Some(entry) = seq.next_element::<[String; 2]>()? {
109                    match entry[0].as_str() {
110                        LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT => {
111                            text = Some(entry[1].clone());
112                        }
113                        LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_LONG => {
114                            long_text = Some(entry[1].clone());
115                        }
116                        LNURL_OFFER_METADATA_ENTRY_TYPE_PNG_IMAGE => {
117                            let decoded = BASE64_STANDARD.decode(&entry[1]).map_err(|e| {
118                                Error::custom(format!("Invalid base64 PNG data: {e}"))
119                            })?;
120                            image = Some(OfferMetadataImage::Png(decoded));
121                        }
122                        LNURL_OFFER_METADATA_ENTRY_TYPE_JPEG_IMAGE => {
123                            let decoded = BASE64_STANDARD.decode(&entry[1]).map_err(|e| {
124                                Error::custom(format!("Invalid base64 JPEG data: {e}"))
125                            })?;
126                            image = Some(OfferMetadataImage::Jpeg(decoded));
127                        }
128                        LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_IDENTIFIER => {
129                            let email = entry[1].parse().map_err(|e| {
130                                Error::custom(format!("Invalid email address: {e}"))
131                            })?;
132                            identifier = Some(OfferMetadataIdentifier::Text(email));
133                        }
134                        LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_EMAIL => {
135                            let email = entry[1].parse().map_err(|e| {
136                                Error::custom(format!("Invalid email address: {e}"))
137                            })?;
138                            identifier = Some(OfferMetadataIdentifier::Email(email));
139                        }
140                        _ => {
141                            // Unknown metadata type, skip it
142                        }
143                    }
144                }
145
146                let text =
147                    text.ok_or_else(|| Error::custom("Missing required 'text/plain' metadata"))?;
148
149                let metadata = OfferMetadataSparse {
150                    text,
151                    long_text,
152                    image,
153                    identifier,
154                };
155
156                Ok(LnUrlOfferMetadata(metadata))
157            }
158        }
159
160        deserializer.deserialize_seq(LnUrlOfferMetadataVisitor)
161    }
162}
163
164const LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT: &str = "text/plain";
165const LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_LONG: &str = "text/long-desc";
166const LNURL_OFFER_METADATA_ENTRY_TYPE_PNG_IMAGE: &str = "image/png;base64";
167const LNURL_OFFER_METADATA_ENTRY_TYPE_JPEG_IMAGE: &str = "image/jpeg;base64";
168const LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_IDENTIFIER: &str = "text/identifier";
169const LNURL_OFFER_METADATA_ENTRY_TYPE_TEXT_EMAIL: &str = "text/email";
170
171#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub enum LnUrlOfferTag {
174    PayRequest,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct LnUrlInvoice {
180    pub pr: String,
181    pub routes: Vec<EmptyJsonValue>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
185pub enum EmptyJsonValue {}
186
187#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct LnUrlError {
190    pub status: LnUrlErrorStatus,
191    pub reason: String,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
195#[serde(rename_all = "UPPERCASE")]
196pub enum LnUrlErrorStatus {
197    Error,
198}
199
200#[cfg(test)]
201mod test {
202    use crate::lnurl::{
203        LnUrlError, LnUrlErrorStatus, LnUrlInvoice, LnUrlOffer, LnUrlOfferMetadata, LnUrlOfferTag,
204    };
205    use crate::offer::{OfferMetadataIdentifier, OfferMetadataImage, OfferMetadataSparse};
206    use bitcoin_hashes::{sha256, Hash};
207    use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret};
208    use secp256k1_0_29::{Secp256k1, SecretKey};
209    use std::time::SystemTime;
210    use url::Url;
211
212    #[test]
213    fn serialize_lnurloffermetadata_metadata() {
214        let metadata = serde_json::to_string(&LnUrlOfferMetadata(OfferMetadataSparse {
215            text: "text".to_string(),
216            long_text: Some("long text".to_string()),
217            image: Some(OfferMetadataImage::Png(vec![0, 1])),
218            identifier: Some(OfferMetadataIdentifier::Email(
219                "email@example.com".parse().unwrap(),
220            )),
221        }))
222        .unwrap();
223        assert_eq!(
224            r#"[["text/plain","text"],["text/long-desc","long text"],["image/png;base64","AAE="],["text/email","email@example.com"]]"#,
225            metadata.as_str()
226        );
227    }
228
229    #[test]
230    fn deserialize_lnurloffermetadata_metadata() {
231        let json = r#"[["text/plain","text"],["text/long-desc","long text"],["image/png;base64","AAE="],["text/email","email@example.com"]]"#;
232
233        let metadata: LnUrlOfferMetadata = serde_json::from_str(json).unwrap();
234
235        assert_eq!(metadata.0.text, "text");
236        assert_eq!(metadata.0.long_text, Some("long text".to_string()));
237        assert_eq!(metadata.0.image, Some(OfferMetadataImage::Png(vec![0, 1])));
238        assert_eq!(
239            metadata.0.identifier,
240            Some(OfferMetadataIdentifier::Email(
241                "email@example.com".parse().unwrap()
242            ))
243        );
244    }
245
246    #[test]
247    fn roundtrip_lnurloffermetadata_serialization() {
248        let original = LnUrlOfferMetadata(OfferMetadataSparse {
249            text: "text".to_string(),
250            long_text: Some("long text".to_string()),
251            image: Some(OfferMetadataImage::Png(vec![0, 1])),
252            identifier: Some(OfferMetadataIdentifier::Email(
253                "email@example.com".parse().unwrap(),
254            )),
255        });
256
257        let serialized = serde_json::to_string(&original).unwrap();
258        let deserialized: LnUrlOfferMetadata = serde_json::from_str(&serialized).unwrap();
259
260        assert_eq!(original, deserialized);
261    }
262
263    #[test]
264    fn deserialize_lnurloffermetadata_minimal() {
265        let json = r#"[["text/plain","minimal text"]]"#;
266
267        let metadata: LnUrlOfferMetadata = serde_json::from_str(json).unwrap();
268
269        assert_eq!(metadata.0.text, "minimal text");
270        assert_eq!(metadata.0.long_text, None);
271        assert_eq!(metadata.0.image, None);
272        assert_eq!(metadata.0.identifier, None);
273    }
274
275    #[test]
276    fn deserialize_lnurloffermetadata_missing_text_fails() {
277        let json = r#"[["text/long-desc","long text only"]]"#;
278
279        let result: Result<LnUrlOfferMetadata, _> = serde_json::from_str(json);
280        assert!(result.is_err());
281        assert!(result
282            .unwrap_err()
283            .to_string()
284            .contains("Missing required 'text/plain' metadata"));
285    }
286
287    #[test]
288    fn deserialize_lnurloffermetadata_unknown_types_ignored() {
289        let json =
290            r#"[["text/plain","text"],["unknown/type","ignored"],["text/long-desc","long text"]]"#;
291
292        let metadata: LnUrlOfferMetadata = serde_json::from_str(json).unwrap();
293
294        assert_eq!(metadata.0.text, "text");
295        assert_eq!(metadata.0.long_text, Some("long text".to_string()));
296        assert_eq!(metadata.0.image, None);
297        assert_eq!(metadata.0.identifier, None);
298    }
299
300    #[test]
301    fn serialize_when_offer_with_metadata_then_returns_json_with_embedded_metadata() {
302        let offer = LnUrlOffer {
303            callback: Url::parse("https://example.com/callback").unwrap(),
304            max_sendable: 0,
305            min_sendable: 0,
306            tag: LnUrlOfferTag::PayRequest,
307            metadata: serde_json::to_string(&LnUrlOfferMetadata(OfferMetadataSparse {
308                text: "text".to_string(),
309                long_text: Some("long text".to_string()),
310                image: Some(OfferMetadataImage::Png(vec![0, 1])),
311                identifier: Some(OfferMetadataIdentifier::Email(
312                    "email@example.com".parse().unwrap(),
313                )),
314            }))
315            .unwrap(),
316            comment_allowed: None,
317        };
318
319        let offer = serde_json::to_string(&offer).unwrap();
320        assert_eq!(
321            r#"{"callback":"https://example.com/callback","maxSendable":0,"minSendable":0,"tag":"payRequest","metadata":"[[\"text/plain\",\"text\"],[\"text/long-desc\",\"long text\"],[\"image/png;base64\",\"AAE=\"],[\"text/email\",\"email@example.com\"]]"}"#,
322            offer.as_str()
323        );
324    }
325
326    #[test]
327    fn serialize_when_invoice_with_payment_request_then_returns_json_with_pr_field() {
328        let private_key = SecretKey::from_slice(
329            &[
330                0xe1, 0x26, 0xf6, 0x8f, 0x7e, 0xaf, 0xcc, 0x8b, 0x74, 0xf5, 0x4d, 0x26, 0x9f, 0xe2,
331                0x06, 0xbe, 0x71, 0x50, 0x00, 0xf9, 0x4d, 0xac, 0x06, 0x7d, 0x1c, 0x04, 0xa8, 0xca,
332                0x3b, 0x2d, 0xb7, 0x34,
333            ][..],
334        )
335        .unwrap();
336
337        let payment_hash = sha256::Hash::from_byte_array([0; 32]);
338        let payment_secret = PaymentSecret([42u8; 32]);
339
340        let invoice = LnUrlInvoice {
341            pr: InvoiceBuilder::new(Currency::Bitcoin)
342                .description("desc".into())
343                .payment_hash(payment_hash)
344                .payment_secret(payment_secret)
345                .timestamp(SystemTime::UNIX_EPOCH)
346                .min_final_cltv_expiry_delta(144)
347                .build_signed(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &private_key))
348                .unwrap()
349                .to_string(),
350            routes: vec![],
351        };
352
353        let invoice = serde_json::to_string(&invoice).unwrap();
354        assert_eq!(
355            r#"{"pr":"lnbc1qqqqqqqdq8v3jhxccpp5qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsp59g4z52329g4z52329g4z52329g4z52329g4z52329g4z52329g4q9qrsgqcqzysvmeka2qvrqmwhjjh7tx333ssfzfw95432jvd3ne046fvtlzaq0zns05tgfvvfu9jjx9uv0xehscf709styuhzza5fvdqf2374dycxqgp3ym4t6","routes":[]}"#,
356            invoice.as_str()
357        );
358    }
359
360    #[test]
361    fn serialize_when_error_with_status_reason_then_returns_json() {
362        let error = LnUrlError {
363            status: LnUrlErrorStatus::Error,
364            reason: "reason".to_string(),
365        };
366
367        let error = serde_json::to_string(&error).unwrap();
368        assert_eq!(r#"{"status":"ERROR","reason":"reason"}"#, error.as_str());
369    }
370
371    #[test]
372    fn deserialize_lnurloffer_with_tag() {
373        let json = r#"{
374            "callback": "https://example.com/callback",
375            "maxSendable": 1000000,
376            "minSendable": 1000,
377            "tag": "payRequest",
378            "metadata": "test metadata"
379        }"#;
380
381        let offer: LnUrlOffer = serde_json::from_str(json).unwrap();
382        assert_eq!(offer.tag, LnUrlOfferTag::PayRequest);
383        assert_eq!(offer.callback.as_str(), "https://example.com/callback");
384        assert_eq!(offer.max_sendable, 1000000);
385        assert_eq!(offer.min_sendable, 1000);
386        assert_eq!(offer.metadata, "test metadata");
387    }
388}