breez_sdk_spark/sdk/
lightning_address.rs

1use lnurl_models::sanitize_username;
2
3use crate::{
4    CheckLightningAddressRequest, LightningAddressInfo, LnurlInfo, RegisterLightningAddressRequest,
5    error::SdkError, persist::ObjectCacheRepository,
6};
7
8use super::BreezSdk;
9
10#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
11#[allow(clippy::needless_pass_by_value)]
12impl BreezSdk {
13    pub async fn check_lightning_address_available(
14        &self,
15        req: CheckLightningAddressRequest,
16    ) -> Result<bool, SdkError> {
17        let Some(client) = &self.lnurl_server_client else {
18            return Err(SdkError::Generic(
19                "LNURL server is not configured".to_string(),
20            ));
21        };
22
23        let username = sanitize_username(&req.username);
24        let available = client.check_username_available(&username).await?;
25        Ok(available)
26    }
27
28    pub async fn get_lightning_address(&self) -> Result<Option<LightningAddressInfo>, SdkError> {
29        let cache = ObjectCacheRepository::new(self.storage.clone());
30        let cached = cache.fetch_lightning_address().await?;
31        if cached.is_none() && self.lnurl_server_client.is_some() {
32            return self.recover_lightning_address().await;
33        }
34        Ok(cached.flatten())
35    }
36
37    pub async fn register_lightning_address(
38        &self,
39        request: RegisterLightningAddressRequest,
40    ) -> Result<LightningAddressInfo, SdkError> {
41        let cache = ObjectCacheRepository::new(self.storage.clone());
42        let Some(client) = &self.lnurl_server_client else {
43            return Err(SdkError::Generic(
44                "LNURL server is not configured".to_string(),
45            ));
46        };
47
48        let username = sanitize_username(&request.username);
49
50        let description = match request.description {
51            Some(description) => description,
52            None => format!("Pay to {}@{}", username, client.domain()),
53        };
54
55        let params = crate::lnurl::RegisterLightningAddressRequest {
56            username: username.clone(),
57            description: description.clone(),
58        };
59
60        let response = client.register_lightning_address(&params).await?;
61        let address_info = LightningAddressInfo {
62            lightning_address: response.lightning_address,
63            description,
64            lnurl: LnurlInfo::new(response.lnurl),
65            username,
66        };
67        cache.save_lightning_address(&address_info, false).await?;
68        Ok(address_info)
69    }
70
71    pub async fn delete_lightning_address(&self) -> Result<(), SdkError> {
72        let cache = ObjectCacheRepository::new(self.storage.clone());
73        let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
74            return Ok(());
75        };
76
77        let Some(client) = &self.lnurl_server_client else {
78            return Err(SdkError::Generic(
79                "LNURL server is not configured".to_string(),
80            ));
81        };
82
83        let params = crate::lnurl::UnregisterLightningAddressRequest {
84            username: address_info.username,
85        };
86
87        client.unregister_lightning_address(&params).await?;
88        cache.delete_lightning_address(false).await?;
89        Ok(())
90    }
91}
92
93// Private lightning address methods
94impl BreezSdk {
95    /// Attempts to recover a lightning address from the lnurl server.
96    pub(super) async fn recover_lightning_address(
97        &self,
98    ) -> Result<Option<LightningAddressInfo>, SdkError> {
99        let cache = ObjectCacheRepository::new(self.storage.clone());
100
101        let Some(client) = &self.lnurl_server_client else {
102            return Err(SdkError::Generic(
103                "LNURL server is not configured".to_string(),
104            ));
105        };
106        let resp = client.recover_lightning_address().await?;
107
108        let result = if let Some(resp) = resp {
109            let address_info = resp.into();
110            cache.save_lightning_address(&address_info, true).await?;
111            Some(address_info)
112        } else {
113            cache.delete_lightning_address(true).await?;
114            None
115        };
116
117        Ok(result)
118    }
119}
120
121#[cfg(test)]
122#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
123mod tests {
124    use std::{path::PathBuf, sync::Arc};
125
126    use crate::{LightningAddressInfo, LnurlInfo, persist::sqlite::SqliteStorage};
127
128    use crate::persist::ObjectCacheRepository;
129
130    fn create_temp_dir(name: &str) -> PathBuf {
131        let mut path = std::env::temp_dir();
132        path.push(format!("breez-test-{}-{}", name, uuid::Uuid::new_v4()));
133        std::fs::create_dir_all(&path).unwrap();
134        path
135    }
136
137    fn create_temp_storage(name: &str) -> (Arc<SqliteStorage>, PathBuf) {
138        let dir = create_temp_dir(name);
139        let storage = SqliteStorage::new(&dir).expect("Failed to create storage");
140        (Arc::new(storage), dir)
141    }
142
143    fn sample_address_info() -> LightningAddressInfo {
144        LightningAddressInfo {
145            lightning_address: "test@example.com".to_string(),
146            username: "test".to_string(),
147            description: "Test address".to_string(),
148            lnurl: LnurlInfo::new("https://example.com/.well-known/lnurlp/test".to_string()),
149        }
150    }
151
152    #[tokio::test]
153    async fn test_fetch_returns_none_when_never_recovered() {
154        let (storage, _dir) = create_temp_storage("never_recovered");
155        let cache = ObjectCacheRepository::new(storage as Arc<_>);
156
157        // Key absent -> None (never recovered)
158        let result = cache.fetch_lightning_address().await.unwrap();
159        assert!(result.is_none());
160    }
161
162    #[tokio::test]
163    async fn test_fetch_returns_some_none_after_delete() {
164        let (storage, _dir) = create_temp_storage("after_delete");
165        let cache = ObjectCacheRepository::new(storage as Arc<_>);
166
167        // Save an address, then delete it
168        cache
169            .save_lightning_address(&sample_address_info(), false)
170            .await
171            .unwrap();
172        cache.delete_lightning_address(false).await.unwrap();
173
174        // Key present, value null -> Some(None) (recovered, no address)
175        let result = cache.fetch_lightning_address().await.unwrap();
176        assert!(
177            matches!(result, Some(None)),
178            "Expected Some(None) after delete"
179        );
180    }
181
182    #[tokio::test]
183    async fn test_fetch_returns_some_some_after_save() {
184        let (storage, _dir) = create_temp_storage("after_save");
185        let cache = ObjectCacheRepository::new(storage as Arc<_>);
186
187        cache
188            .save_lightning_address(&sample_address_info(), false)
189            .await
190            .unwrap();
191
192        // Key present, value non-null -> Some(Some(info))
193        let result = cache.fetch_lightning_address().await.unwrap();
194        let info = result
195            .flatten()
196            .expect("Expected Some(Some(info)) after save");
197        assert_eq!(info.lightning_address, "test@example.com");
198    }
199}