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, lnurl::LnurlServerError, 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        match client.unregister_lightning_address(&params).await {
157            Ok(()) => {}
158            // A 409 means the cached username is not the address the server
159            // holds, so the signature authorized deleting a name this wallet no
160            // longer has (another device re-registered under the same identity
161            // key). Resync from the server, which leaves a retry signing the
162            // real address.
163            Err(
164                e @ LnurlServerError::Network {
165                    statuscode: 409, ..
166                },
167            ) => {
168                self.recover_lightning_address().await?;
169                return Err(e.into());
170            }
171            Err(e) => return Err(e.into()),
172        }
173
174        cache.delete_lightning_address(false).await?;
175        Ok(())
176    }
177}
178
179// Private lightning address methods
180impl BreezSdk {
181    /// Attempts to recover a lightning address from the lnurl server.
182    pub(super) async fn recover_lightning_address(
183        &self,
184    ) -> Result<Option<LightningAddressInfo>, SdkError> {
185        let cache = ObjectCacheRepository::new(self.storage.clone());
186
187        let Some(client) = &self.lnurl_server_client else {
188            return Err(SdkError::Generic(
189                "LNURL server is not configured".to_string(),
190            ));
191        };
192        let resp = client.recover_lightning_address().await?;
193
194        let result = if let Some(resp) = resp {
195            let address_info = resp.into();
196            cache.save_lightning_address(&address_info, true).await?;
197            Some(address_info)
198        } else {
199            cache.delete_lightning_address(true).await?;
200            None
201        };
202
203        Ok(result)
204    }
205}
206
207#[cfg(test)]
208#[cfg(feature = "sqlite")]
209mod tests {
210    use std::{path::PathBuf, sync::Arc};
211
212    use crate::{LightningAddressInfo, LnurlInfo, persist::sqlite::SqliteStorage};
213
214    use crate::persist::ObjectCacheRepository;
215
216    fn create_temp_dir(name: &str) -> PathBuf {
217        let mut path = std::env::temp_dir();
218        path.push(format!("breez-test-{}-{}", name, uuid::Uuid::new_v4()));
219        std::fs::create_dir_all(&path).unwrap();
220        path
221    }
222
223    fn create_temp_storage(name: &str) -> (Arc<SqliteStorage>, PathBuf) {
224        let dir = create_temp_dir(name);
225        let storage = SqliteStorage::new(&dir).expect("Failed to create storage");
226        (Arc::new(storage), dir)
227    }
228
229    fn sample_address_info() -> LightningAddressInfo {
230        LightningAddressInfo {
231            lightning_address: "test@example.com".to_string(),
232            username: "test".to_string(),
233            description: "Test address".to_string(),
234            lnurl: LnurlInfo::new("https://example.com/.well-known/lnurlp/test".to_string()),
235        }
236    }
237
238    #[tokio::test]
239    async fn test_fetch_returns_none_when_never_recovered() {
240        let (storage, _dir) = create_temp_storage("never_recovered");
241        let cache = ObjectCacheRepository::new(storage as Arc<_>);
242
243        // Key absent -> None (never recovered)
244        let result = cache.fetch_lightning_address().await.unwrap();
245        assert!(result.is_none());
246    }
247
248    #[tokio::test]
249    async fn test_fetch_returns_some_none_after_delete() {
250        let (storage, _dir) = create_temp_storage("after_delete");
251        let cache = ObjectCacheRepository::new(storage as Arc<_>);
252
253        // Save an address, then delete it
254        cache
255            .save_lightning_address(&sample_address_info(), false)
256            .await
257            .unwrap();
258        cache.delete_lightning_address(false).await.unwrap();
259
260        // Key present, value null -> Some(None) (recovered, no address)
261        let result = cache.fetch_lightning_address().await.unwrap();
262        assert!(
263            matches!(result, Some(None)),
264            "Expected Some(None) after delete"
265        );
266    }
267
268    #[tokio::test]
269    async fn test_fetch_returns_some_some_after_save() {
270        let (storage, _dir) = create_temp_storage("after_save");
271        let cache = ObjectCacheRepository::new(storage as Arc<_>);
272
273        cache
274            .save_lightning_address(&sample_address_info(), false)
275            .await
276            .unwrap();
277
278        // Key present, value non-null -> Some(Some(info))
279        let result = cache.fetch_lightning_address().await.unwrap();
280        let info = result
281            .flatten()
282            .expect("Expected Some(Some(info)) after save");
283        assert_eq!(info.lightning_address, "test@example.com");
284    }
285}