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 }
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}