Skip to main content

breez_sdk_spark/sdk/
lightning_address.rs

1use bitcoin::hex::DisplayHex;
2use lnurl_models::sanitize_username;
3
4use crate::{
5    AuthorizeTransferRequest, CheckLightningAddressRequest, ClaimTransferRequest,
6    LightningAddressInfo, LnurlInfo, RegisterLightningAddressRequest, TransferAuthorization,
7    error::SdkError, persist::ObjectCacheRepository,
8};
9
10use super::BreezSdk;
11
12#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
13#[allow(clippy::needless_pass_by_value)]
14impl BreezSdk {
15    pub async fn check_lightning_address_available(
16        &self,
17        req: CheckLightningAddressRequest,
18    ) -> Result<bool, SdkError> {
19        let Some(client) = &self.lnurl_server_client else {
20            return Err(SdkError::Generic(
21                "LNURL server is not configured".to_string(),
22            ));
23        };
24
25        let username = sanitize_username(&req.username);
26        let available = client.check_username_available(&username).await?;
27        Ok(available)
28    }
29
30    pub async fn get_lightning_address(&self) -> Result<Option<LightningAddressInfo>, SdkError> {
31        let cache = ObjectCacheRepository::new(self.storage.clone());
32        let cached = cache.fetch_lightning_address().await?;
33        if cached.is_none() && self.lnurl_server_client.is_some() {
34            return self.recover_lightning_address().await;
35        }
36        Ok(cached.flatten())
37    }
38
39    pub async fn register_lightning_address(
40        &self,
41        request: RegisterLightningAddressRequest,
42    ) -> Result<LightningAddressInfo, SdkError> {
43        let cache = ObjectCacheRepository::new(self.storage.clone());
44        let Some(client) = &self.lnurl_server_client else {
45            return Err(SdkError::Generic(
46                "LNURL server is not configured".to_string(),
47            ));
48        };
49
50        let username = sanitize_username(&request.username);
51
52        let description = match request.description {
53            Some(description) => description,
54            None => format!("Pay to {}@{}", username, client.domain()),
55        };
56
57        let params = crate::lnurl::RegisterLightningAddressRequest {
58            username: username.clone(),
59            description: description.clone(),
60        };
61
62        let response = client.register_lightning_address(&params).await?;
63        let address_info = LightningAddressInfo {
64            lightning_address: response.lightning_address,
65            description,
66            lnurl: LnurlInfo::new(response.lnurl),
67            username,
68        };
69        cache.save_lightning_address(&address_info, false).await?;
70        Ok(address_info)
71    }
72
73    /// Authorize transferring the current owner's registered lightning address
74    /// username to `request.transferee_pubkey`. Returns a
75    /// [`TransferAuthorization`] to hand to the new owner, who
76    /// claims it via [`BreezSdk::claim_lightning_address_transfer`].
77    /// Errors if the current owner has no lightning address registered.
78    pub async fn authorize_lightning_address_transfer(
79        &self,
80        request: AuthorizeTransferRequest,
81    ) -> Result<TransferAuthorization, SdkError> {
82        let cache = ObjectCacheRepository::new(self.storage.clone());
83        let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
84            return Err(SdkError::Generic(
85                "No lightning address registered to transfer".to_string(),
86            ));
87        };
88        let self_pubkey = self.spark_wallet.get_identity_public_key().to_string();
89        let message = format!(
90            "transfer:{}-{}",
91            address_info.username, request.transferee_pubkey
92        );
93        let signature = self.spark_wallet.sign_message(&message).await?;
94        Ok(TransferAuthorization {
95            username: address_info.username,
96            pubkey: self_pubkey,
97            signature: signature.serialize_der().to_lower_hex_string(),
98        })
99    }
100
101    /// Claim a lightning address username handed over by its current owner,
102    /// using the [`TransferAuthorization`] from
103    /// [`BreezSdk::authorize_lightning_address_transfer`]. Completes the
104    /// takeover and returns the newly-owned address.
105    pub async fn claim_lightning_address_transfer(
106        &self,
107        request: ClaimTransferRequest,
108    ) -> Result<LightningAddressInfo, SdkError> {
109        let cache = ObjectCacheRepository::new(self.storage.clone());
110        let Some(client) = &self.lnurl_server_client else {
111            return Err(SdkError::Generic(
112                "LNURL server is not configured".to_string(),
113            ));
114        };
115
116        let username = sanitize_username(&request.authorization.username);
117        let description = match request.description {
118            Some(description) => description,
119            None => format!("Pay to {}@{}", username, client.domain()),
120        };
121
122        let params = crate::lnurl::TransferLightningAddressRequest {
123            username: username.clone(),
124            description: description.clone(),
125            from_pubkey: request.authorization.pubkey,
126            from_signature: request.authorization.signature,
127        };
128
129        let response = client.transfer_lightning_address(&params).await?;
130        let address_info = LightningAddressInfo {
131            lightning_address: response.lightning_address,
132            description,
133            lnurl: LnurlInfo::new(response.lnurl),
134            username,
135        };
136        cache.save_lightning_address(&address_info, false).await?;
137        Ok(address_info)
138    }
139
140    pub async fn delete_lightning_address(&self) -> Result<(), SdkError> {
141        let cache = ObjectCacheRepository::new(self.storage.clone());
142        let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
143            return Ok(());
144        };
145
146        let Some(client) = &self.lnurl_server_client else {
147            return Err(SdkError::Generic(
148                "LNURL server is not configured".to_string(),
149            ));
150        };
151
152        let params = crate::lnurl::UnregisterLightningAddressRequest {
153            username: address_info.username,
154        };
155
156        client.unregister_lightning_address(&params).await?;
157        cache.delete_lightning_address(false).await?;
158        Ok(())
159    }
160}
161
162// Private lightning address methods
163impl BreezSdk {
164    /// Attempts to recover a lightning address from the lnurl server.
165    pub(super) async fn recover_lightning_address(
166        &self,
167    ) -> Result<Option<LightningAddressInfo>, SdkError> {
168        let cache = ObjectCacheRepository::new(self.storage.clone());
169
170        let Some(client) = &self.lnurl_server_client else {
171            return Err(SdkError::Generic(
172                "LNURL server is not configured".to_string(),
173            ));
174        };
175        let resp = client.recover_lightning_address().await?;
176
177        let result = if let Some(resp) = resp {
178            let address_info = resp.into();
179            cache.save_lightning_address(&address_info, true).await?;
180            Some(address_info)
181        } else {
182            cache.delete_lightning_address(true).await?;
183            None
184        };
185
186        Ok(result)
187    }
188}
189
190#[cfg(test)]
191#[cfg(feature = "sqlite")]
192mod tests {
193    use std::{path::PathBuf, sync::Arc};
194
195    use crate::{LightningAddressInfo, LnurlInfo, persist::sqlite::SqliteStorage};
196
197    use crate::persist::ObjectCacheRepository;
198
199    fn create_temp_dir(name: &str) -> PathBuf {
200        let mut path = std::env::temp_dir();
201        path.push(format!("breez-test-{}-{}", name, uuid::Uuid::new_v4()));
202        std::fs::create_dir_all(&path).unwrap();
203        path
204    }
205
206    fn create_temp_storage(name: &str) -> (Arc<SqliteStorage>, PathBuf) {
207        let dir = create_temp_dir(name);
208        let storage = SqliteStorage::new(&dir).expect("Failed to create storage");
209        (Arc::new(storage), dir)
210    }
211
212    fn sample_address_info() -> LightningAddressInfo {
213        LightningAddressInfo {
214            lightning_address: "test@example.com".to_string(),
215            username: "test".to_string(),
216            description: "Test address".to_string(),
217            lnurl: LnurlInfo::new("https://example.com/.well-known/lnurlp/test".to_string()),
218        }
219    }
220
221    #[tokio::test]
222    async fn test_fetch_returns_none_when_never_recovered() {
223        let (storage, _dir) = create_temp_storage("never_recovered");
224        let cache = ObjectCacheRepository::new(storage as Arc<_>);
225
226        // Key absent -> None (never recovered)
227        let result = cache.fetch_lightning_address().await.unwrap();
228        assert!(result.is_none());
229    }
230
231    #[tokio::test]
232    async fn test_fetch_returns_some_none_after_delete() {
233        let (storage, _dir) = create_temp_storage("after_delete");
234        let cache = ObjectCacheRepository::new(storage as Arc<_>);
235
236        // Save an address, then delete it
237        cache
238            .save_lightning_address(&sample_address_info(), false)
239            .await
240            .unwrap();
241        cache.delete_lightning_address(false).await.unwrap();
242
243        // Key present, value null -> Some(None) (recovered, no address)
244        let result = cache.fetch_lightning_address().await.unwrap();
245        assert!(
246            matches!(result, Some(None)),
247            "Expected Some(None) after delete"
248        );
249    }
250
251    #[tokio::test]
252    async fn test_fetch_returns_some_some_after_save() {
253        let (storage, _dir) = create_temp_storage("after_save");
254        let cache = ObjectCacheRepository::new(storage as Arc<_>);
255
256        cache
257            .save_lightning_address(&sample_address_info(), false)
258            .await
259            .unwrap();
260
261        // Key present, value non-null -> Some(Some(info))
262        let result = cache.fetch_lightning_address().await.unwrap();
263        let info = result
264            .flatten()
265            .expect("Expected Some(Some(info)) after save");
266        assert_eq!(info.lightning_address, "test@example.com");
267    }
268}