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        // Ensure spark private mode is initialized before registering
42        self.ensure_spark_private_mode_initialized().await?;
43
44        self.register_lightning_address_internal(request).await
45    }
46
47    pub async fn delete_lightning_address(&self) -> Result<(), SdkError> {
48        let cache = ObjectCacheRepository::new(self.storage.clone());
49        let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
50            return Ok(());
51        };
52
53        let Some(client) = &self.lnurl_server_client else {
54            return Err(SdkError::Generic(
55                "LNURL server is not configured".to_string(),
56            ));
57        };
58
59        let params = crate::lnurl::UnregisterLightningAddressRequest {
60            username: address_info.username,
61        };
62
63        client.unregister_lightning_address(&params).await?;
64        cache.delete_lightning_address().await?;
65        Ok(())
66    }
67}
68
69// Private lightning address methods
70impl BreezSdk {
71    /// Attempts to recover a lightning address from the lnurl server.
72    pub(super) async fn recover_lightning_address(
73        &self,
74    ) -> Result<Option<LightningAddressInfo>, SdkError> {
75        let cache = ObjectCacheRepository::new(self.storage.clone());
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        let resp = client.recover_lightning_address().await?;
83
84        let result = if let Some(resp) = resp {
85            let address_info = resp.into();
86            cache.save_lightning_address(&address_info).await?;
87            Some(address_info)
88        } else {
89            cache.delete_lightning_address().await?;
90            None
91        };
92
93        Ok(result)
94    }
95
96    pub(super) async fn register_lightning_address_internal(
97        &self,
98        request: RegisterLightningAddressRequest,
99    ) -> Result<LightningAddressInfo, SdkError> {
100        let cache = ObjectCacheRepository::new(self.storage.clone());
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
107        let username = sanitize_username(&request.username);
108
109        let description = match request.description {
110            Some(description) => description,
111            None => format!("Pay to {}@{}", username, client.domain()),
112        };
113
114        let params = crate::lnurl::RegisterLightningAddressRequest {
115            username: username.clone(),
116            description: description.clone(),
117            lnurl_private_mode_enabled: !self.config.support_lnurl_verify,
118        };
119
120        let response = client.register_lightning_address(&params).await?;
121        let address_info = LightningAddressInfo {
122            lightning_address: response.lightning_address,
123            description,
124            lnurl: LnurlInfo::new(response.lnurl),
125            username,
126        };
127        cache.save_lightning_address(&address_info).await?;
128        Ok(address_info)
129    }
130}
131
132#[cfg(test)]
133#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
134mod tests {
135    use std::{path::PathBuf, sync::Arc};
136
137    use crate::{
138        LightningAddressInfo, LnurlInfo, persist::Storage, persist::sqlite::SqliteStorage,
139    };
140
141    use crate::persist::ObjectCacheRepository;
142
143    fn create_temp_dir(name: &str) -> PathBuf {
144        let mut path = std::env::temp_dir();
145        path.push(format!("breez-test-{}-{}", name, uuid::Uuid::new_v4()));
146        std::fs::create_dir_all(&path).unwrap();
147        path
148    }
149
150    fn create_temp_storage(name: &str) -> (Arc<SqliteStorage>, PathBuf) {
151        let dir = create_temp_dir(name);
152        let storage = SqliteStorage::new(&dir).expect("Failed to create storage");
153        (Arc::new(storage), dir)
154    }
155
156    fn sample_address_info() -> LightningAddressInfo {
157        LightningAddressInfo {
158            lightning_address: "test@example.com".to_string(),
159            username: "test".to_string(),
160            description: "Test address".to_string(),
161            lnurl: LnurlInfo::new("https://example.com/.well-known/lnurlp/test".to_string()),
162        }
163    }
164
165    #[tokio::test]
166    async fn test_fetch_returns_none_when_never_recovered() {
167        let (storage, _dir) = create_temp_storage("never_recovered");
168        let cache = ObjectCacheRepository::new(storage as Arc<_>);
169
170        // Key absent -> None (never recovered)
171        let result = cache.fetch_lightning_address().await.unwrap();
172        assert!(result.is_none());
173    }
174
175    #[tokio::test]
176    async fn test_fetch_returns_some_none_after_delete() {
177        let (storage, _dir) = create_temp_storage("after_delete");
178        let cache = ObjectCacheRepository::new(storage as Arc<_>);
179
180        // Save an address, then delete it
181        cache
182            .save_lightning_address(&sample_address_info())
183            .await
184            .unwrap();
185        cache.delete_lightning_address().await.unwrap();
186
187        // Key present, value null -> Some(None) (recovered, no address)
188        let result = cache.fetch_lightning_address().await.unwrap();
189        assert!(
190            matches!(result, Some(None)),
191            "Expected Some(None) after delete"
192        );
193    }
194
195    #[tokio::test]
196    async fn test_fetch_returns_some_some_after_save() {
197        let (storage, _dir) = create_temp_storage("after_save");
198        let cache = ObjectCacheRepository::new(storage as Arc<_>);
199
200        cache
201            .save_lightning_address(&sample_address_info())
202            .await
203            .unwrap();
204
205        // Key present, value non-null -> Some(Some(info))
206        let result = cache.fetch_lightning_address().await.unwrap();
207        let info = result
208            .flatten()
209            .expect("Expected Some(Some(info)) after save");
210        assert_eq!(info.lightning_address, "test@example.com");
211    }
212
213    #[tokio::test]
214    async fn test_backward_compat_old_cached_json() {
215        let (storage, _dir) = create_temp_storage("backward_compat");
216
217        // Simulate old cache format: raw JSON object without Option wrapper
218        let old_value = serde_json::to_string(&sample_address_info()).unwrap();
219        storage
220            .set_cached_item("lightning_address".to_string(), old_value)
221            .await
222            .unwrap();
223
224        let cache = ObjectCacheRepository::new(storage as Arc<_>);
225        let result = cache.fetch_lightning_address().await.unwrap();
226        let info = result
227            .flatten()
228            .expect("Expected old cached JSON to deserialize as Some(info)");
229        assert_eq!(info.lightning_address, "test@example.com");
230    }
231}