1use std::str::FromStr;
2use std::sync::Arc;
3
4use bitcoin::hex::DisplayHex;
5use lnurl_models::{sanitize_username, signed_message};
6use tracing::info;
7
8use crate::utils::time::try_now_secs;
9use crate::{
10 AuthorizeTransferRequest, CheckLightningAddressRequest, ClaimTransferRequest,
11 LightningAddressInfo, LnurlInfo, RegisterLightningAddressRequest, Storage,
12 TransferAuthorization,
13 error::SdkError,
14 lnurl::LnurlServerError,
15 persist::{LIGHTNING_ADDRESS_KEY, ObjectCacheRepository},
16};
17
18use super::BreezSdk;
19
20fn normalized_pubkey(pubkey: &str) -> Result<String, SdkError> {
25 bitcoin::secp256k1::PublicKey::from_str(pubkey)
26 .map(|pubkey| pubkey.to_string())
27 .map_err(|_| SdkError::InvalidInput(format!("'{pubkey}' is not a valid public key")))
28}
29
30fn address_domain(lightning_address: &str) -> Result<String, SdkError> {
36 lightning_address
37 .rsplit_once('@')
38 .map(|(_, domain)| domain.to_ascii_lowercase())
39 .ok_or_else(|| {
40 SdkError::Generic(format!(
41 "cached lightning address '{lightning_address}' has no domain"
42 ))
43 })
44}
45
46fn require_configured_domain(
53 configured_domain: &str,
54 domain: &str,
55 subject: &str,
56) -> Result<(), SdkError> {
57 let configured = crate::lnurl::signed_domain(configured_domain);
58 if domain != configured {
59 return Err(SdkError::InvalidInput(format!(
60 "{subject} names domain '{domain}', but this SDK is configured for '{configured}'"
61 )));
62 }
63 Ok(())
64}
65
66async fn clear_address_on_other_domain(
69 storage: &Arc<dyn Storage>,
70 configured_domain: &str,
71) -> Result<(), SdkError> {
72 let cache = ObjectCacheRepository::new(Arc::clone(storage));
73 let Some(Some(address_info)) = cache.fetch_lightning_address().await? else {
74 return Ok(());
75 };
76 let configured = crate::lnurl::signed_domain(configured_domain);
77 if address_domain(&address_info.lightning_address).is_ok_and(|domain| domain == configured) {
78 return Ok(());
79 }
80 storage
81 .delete_cached_item(LIGHTNING_ADDRESS_KEY.to_string())
82 .await?;
83 info!(
84 "Cleared the cached lightning address {}, which is not on {configured}",
85 address_info.lightning_address
86 );
87 Ok(())
88}
89
90fn shown_description(returned: Option<String>, sent: Option<String>) -> String {
93 returned.or(sent).unwrap_or_default()
94}
95
96#[macros::instance_span]
97#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
98#[allow(clippy::needless_pass_by_value)]
99impl BreezSdk {
100 pub async fn check_lightning_address_available(
106 &self,
107 req: CheckLightningAddressRequest,
108 ) -> Result<bool, SdkError> {
109 let Some(client) = &self.lnurl_server_client else {
110 return Err(SdkError::Generic(
111 "LNURL server is not configured".to_string(),
112 ));
113 };
114
115 let username = sanitize_username(&req.username);
116 let available = client.check_username_available(&username).await?;
117 Ok(available)
118 }
119
120 pub async fn get_lightning_address(&self) -> Result<Option<LightningAddressInfo>, SdkError> {
121 let cache = ObjectCacheRepository::new(self.storage.clone());
122 let cached = cache.fetch_lightning_address().await?;
123 if cached.is_none() && self.lnurl_server_client.is_some() {
124 return self.recover_lightning_address().await;
125 }
126 Ok(cached.flatten())
127 }
128
129 pub async fn register_lightning_address(
130 &self,
131 request: RegisterLightningAddressRequest,
132 ) -> Result<LightningAddressInfo, SdkError> {
133 let cache = ObjectCacheRepository::new(self.storage.clone());
134 let Some(client) = &self.lnurl_server_client else {
135 return Err(SdkError::Generic(
136 "LNURL server is not configured".to_string(),
137 ));
138 };
139
140 let username = sanitize_username(&request.username);
141
142 let params = crate::lnurl::RegisterLightningAddressRequest {
143 username: username.clone(),
144 description: request.description,
145 };
146
147 let response = client.register_lightning_address(¶ms).await?;
148 let address_info = LightningAddressInfo {
149 lightning_address: response.lightning_address,
150 description: shown_description(response.description, params.description),
151 lnurl: LnurlInfo::new(response.lnurl),
152 username,
153 };
154 cache.save_lightning_address(&address_info, false).await?;
155 Ok(address_info)
156 }
157
158 pub async fn authorize_lightning_address_transfer(
164 &self,
165 request: AuthorizeTransferRequest,
166 ) -> Result<TransferAuthorization, SdkError> {
167 let cache = ObjectCacheRepository::new(self.storage.clone());
168 let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
169 return Err(SdkError::Generic(
170 "No lightning address registered to transfer".to_string(),
171 ));
172 };
173
174 let domain = address_domain(&address_info.lightning_address)?;
180
181 let self_pubkey = self.spark_wallet.get_identity_public_key().to_string();
182 let transferee_pubkey = normalized_pubkey(&request.transferee_pubkey)?;
183 let timestamp = try_now_secs()?;
184 let signature = self
185 .spark_wallet
186 .sign_message(&signed_message::transfer_from(
187 &domain,
188 &address_info.username,
189 &self_pubkey,
190 &transferee_pubkey,
191 timestamp,
192 ))
193 .await?;
194
195 Ok(TransferAuthorization {
196 username: address_info.username,
197 pubkey: self_pubkey,
198 signature: signature.serialize_der().to_lower_hex_string(),
199 domain,
200 timestamp,
201 })
202 }
203
204 pub async fn claim_lightning_address_transfer(
209 &self,
210 request: ClaimTransferRequest,
211 ) -> Result<LightningAddressInfo, SdkError> {
212 let cache = ObjectCacheRepository::new(self.storage.clone());
213 let Some(client) = &self.lnurl_server_client else {
214 return Err(SdkError::Generic(
215 "LNURL server is not configured".to_string(),
216 ));
217 };
218
219 require_configured_domain(
222 client.domain(),
223 &request.authorization.domain,
224 "the authorization",
225 )?;
226 if try_now_secs()?.abs_diff(request.authorization.timestamp) > signed_message::VALIDITY_SECS
231 {
232 return Err(SdkError::InvalidInput(
233 "authorization is expired or not yet valid; ask the current owner to \
234 authorize the transfer again"
235 .to_string(),
236 ));
237 }
238
239 let username = sanitize_username(&request.authorization.username);
240
241 let params = crate::lnurl::TransferLightningAddressRequest {
242 username: username.clone(),
243 description: request.description,
244 from_pubkey: normalized_pubkey(&request.authorization.pubkey)?,
245 from_signature: request.authorization.signature,
246 timestamp: request.authorization.timestamp,
247 };
248
249 let response = client.transfer_lightning_address(¶ms).await?;
250 let address_info = LightningAddressInfo {
251 lightning_address: response.lightning_address,
252 description: shown_description(response.description, params.description),
253 lnurl: LnurlInfo::new(response.lnurl),
254 username,
255 };
256 cache.save_lightning_address(&address_info, false).await?;
257 Ok(address_info)
258 }
259
260 pub async fn delete_lightning_address(&self) -> Result<(), SdkError> {
267 let cache = ObjectCacheRepository::new(self.storage.clone());
268 let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
269 return Ok(());
270 };
271
272 let Some(client) = &self.lnurl_server_client else {
273 return Err(SdkError::Generic(
274 "LNURL server is not configured".to_string(),
275 ));
276 };
277
278 let params = crate::lnurl::UnregisterLightningAddressRequest {
279 username: address_info.username,
280 };
281
282 match client.unregister_lightning_address(¶ms).await {
283 Ok(()) => {}
284 Err(
290 e @ LnurlServerError::Network {
291 statuscode: 409, ..
292 },
293 ) => {
294 self.recover_lightning_address().await?;
295 return Err(e.into());
296 }
297 Err(e) => return Err(e.into()),
298 }
299
300 cache.delete_lightning_address(false).await?;
301 Ok(())
302 }
303}
304
305impl BreezSdk {
307 pub(super) async fn clear_lightning_address_on_other_domain(&self) -> Result<(), SdkError> {
308 let Some(client) = &self.lnurl_server_client else {
309 return Ok(());
310 };
311 clear_address_on_other_domain(&self.storage, client.domain()).await
312 }
313
314 pub(super) async fn recover_lightning_address(
316 &self,
317 ) -> Result<Option<LightningAddressInfo>, SdkError> {
318 let cache = ObjectCacheRepository::new(self.storage.clone());
319
320 let Some(client) = &self.lnurl_server_client else {
321 return Err(SdkError::Generic(
322 "LNURL server is not configured".to_string(),
323 ));
324 };
325 let resp = client.recover_lightning_address().await?;
326
327 let result = if let Some(resp) = resp {
328 let address_info = resp.into();
329 cache.save_lightning_address(&address_info, true).await?;
330 Some(address_info)
331 } else {
332 cache.delete_lightning_address(true).await?;
333 None
334 };
335
336 Ok(result)
337 }
338}
339
340#[cfg(test)]
341#[cfg(feature = "sqlite")]
342mod tests {
343 use std::{path::PathBuf, sync::Arc};
344
345 use crate::{LightningAddressInfo, LnurlInfo, Storage, persist::sqlite::SqliteStorage};
346
347 use crate::persist::ObjectCacheRepository;
348
349 use super::clear_address_on_other_domain;
350
351 fn create_temp_dir(name: &str) -> PathBuf {
352 let mut path = std::env::temp_dir();
353 path.push(format!("breez-test-{}-{}", name, uuid::Uuid::new_v4()));
354 std::fs::create_dir_all(&path).unwrap();
355 path
356 }
357
358 fn create_temp_storage(name: &str) -> (Arc<SqliteStorage>, PathBuf) {
359 let dir = create_temp_dir(name);
360 let storage = SqliteStorage::new(&dir).expect("Failed to create storage");
361 (Arc::new(storage), dir)
362 }
363
364 fn sample_address_info() -> LightningAddressInfo {
365 LightningAddressInfo {
366 lightning_address: "test@example.com".to_string(),
367 username: "test".to_string(),
368 description: "Test address".to_string(),
369 lnurl: LnurlInfo::new("https://example.com/.well-known/lnurlp/test".to_string()),
370 }
371 }
372
373 #[tokio::test]
374 async fn test_fetch_returns_none_when_never_recovered() {
375 let (storage, _dir) = create_temp_storage("never_recovered");
376 let cache = ObjectCacheRepository::new(storage as Arc<_>);
377
378 let result = cache.fetch_lightning_address().await.unwrap();
380 assert!(result.is_none());
381 }
382
383 #[tokio::test]
384 async fn test_fetch_returns_some_none_after_delete() {
385 let (storage, _dir) = create_temp_storage("after_delete");
386 let cache = ObjectCacheRepository::new(storage as Arc<_>);
387
388 cache
390 .save_lightning_address(&sample_address_info(), false)
391 .await
392 .unwrap();
393 cache.delete_lightning_address(false).await.unwrap();
394
395 let result = cache.fetch_lightning_address().await.unwrap();
397 assert!(
398 matches!(result, Some(None)),
399 "Expected Some(None) after delete"
400 );
401 }
402
403 #[tokio::test]
404 async fn test_fetch_returns_some_some_after_save() {
405 let (storage, _dir) = create_temp_storage("after_save");
406 let cache = ObjectCacheRepository::new(storage as Arc<_>);
407
408 cache
409 .save_lightning_address(&sample_address_info(), false)
410 .await
411 .unwrap();
412
413 let result = cache.fetch_lightning_address().await.unwrap();
415 let info = result
416 .flatten()
417 .expect("Expected Some(Some(info)) after save");
418 assert_eq!(info.lightning_address, "test@example.com");
419 }
420
421 #[tokio::test]
422 async fn an_address_on_another_domain_is_cleared() {
423 let (storage, _dir) = create_temp_storage("other_domain");
424 let storage: Arc<dyn Storage> = storage;
425 let cache = ObjectCacheRepository::new(Arc::clone(&storage));
426 cache
427 .save_lightning_address(&sample_address_info(), true)
428 .await
429 .unwrap();
430
431 clear_address_on_other_domain(&storage, "https://other.example.com")
432 .await
433 .unwrap();
434
435 assert!(cache.fetch_lightning_address().await.unwrap().is_none());
436 }
437
438 #[tokio::test]
439 async fn an_address_on_the_configured_domain_stays() {
440 let (storage, _dir) = create_temp_storage("configured_domain");
441 let storage: Arc<dyn Storage> = storage;
442 let cache = ObjectCacheRepository::new(Arc::clone(&storage));
443 let address = LightningAddressInfo {
444 lightning_address: "test@127.0.0.1:8080".to_string(),
445 ..sample_address_info()
446 };
447 cache.save_lightning_address(&address, true).await.unwrap();
448
449 clear_address_on_other_domain(&storage, "http://127.0.0.1:8080/")
450 .await
451 .unwrap();
452
453 assert_eq!(
454 cache.fetch_lightning_address().await.unwrap(),
455 Some(Some(address))
456 );
457 }
458
459 #[tokio::test]
462 async fn a_record_of_no_address_stays() {
463 let (storage, _dir) = create_temp_storage("no_address");
464 let storage: Arc<dyn Storage> = storage;
465 let cache = ObjectCacheRepository::new(Arc::clone(&storage));
466 cache.delete_lightning_address(true).await.unwrap();
467
468 clear_address_on_other_domain(&storage, "https://other.example.com")
469 .await
470 .unwrap();
471
472 assert!(matches!(
473 cache.fetch_lightning_address().await.unwrap(),
474 Some(None)
475 ));
476 }
477}
478
479#[cfg(test)]
480mod domain_tests {
481 use super::{address_domain, require_configured_domain};
482
483 #[test]
486 fn the_domain_comes_from_the_cached_address() {
487 assert_eq!(address_domain("alice@example.com").unwrap(), "example.com");
488 assert_eq!(address_domain("alice@Example.COM").unwrap(), "example.com");
490 assert_eq!(address_domain("a@b@example.com").unwrap(), "example.com");
493 assert!(address_domain("not-an-address").is_err());
494 }
495
496 #[test]
500 fn a_configured_domain_matches_however_it_was_written() {
501 for configured in [
502 "example.com",
503 "https://example.com",
504 "https://example.com/",
505 "https://example.com/lnurl",
506 "https://user:pass@example.com",
507 "https://EXAMPLE.com",
508 ] {
509 assert!(
510 require_configured_domain(configured, "example.com", "the authorization").is_ok(),
511 "{configured}"
512 );
513 }
514 }
515
516 #[test]
519 fn a_domain_that_is_not_the_configured_one_names_both() {
520 let error = require_configured_domain(
521 "https://example.com",
522 "other.example.com",
523 "the authorization",
524 )
525 .unwrap_err()
526 .to_string();
527 for expected in ["the authorization", "other.example.com", "example.com"] {
528 assert!(error.contains(expected), "{error}");
529 }
530 }
531}