1use std::str::FromStr;
2
3use bitcoin::hex::DisplayHex;
4use lnurl_models::{sanitize_username, signed_message};
5
6use crate::utils::time::try_now_secs;
7use crate::{
8 AuthorizeTransferRequest, CheckLightningAddressRequest, ClaimTransferRequest,
9 LightningAddressInfo, LnurlInfo, RegisterLightningAddressRequest, TransferAuthorization,
10 error::SdkError, lnurl::LnurlServerError, persist::ObjectCacheRepository,
11};
12
13use super::BreezSdk;
14
15fn normalized_pubkey(pubkey: &str) -> Result<String, SdkError> {
20 bitcoin::secp256k1::PublicKey::from_str(pubkey)
21 .map(|pubkey| pubkey.to_string())
22 .map_err(|_| SdkError::InvalidInput(format!("'{pubkey}' is not a valid public key")))
23}
24
25fn address_domain(lightning_address: &str) -> Result<String, SdkError> {
31 lightning_address
32 .rsplit_once('@')
33 .map(|(_, domain)| domain.to_ascii_lowercase())
34 .ok_or_else(|| {
35 SdkError::Generic(format!(
36 "cached lightning address '{lightning_address}' has no domain"
37 ))
38 })
39}
40
41fn require_configured_domain(
48 configured_domain: &str,
49 domain: &str,
50 subject: &str,
51) -> Result<(), SdkError> {
52 let configured = crate::lnurl::signed_domain(configured_domain);
53 if domain != configured {
54 return Err(SdkError::InvalidInput(format!(
55 "{subject} names domain '{domain}', but this SDK is configured for '{configured}'"
56 )));
57 }
58 Ok(())
59}
60
61fn default_description(username: &str, configured_domain: &str) -> String {
65 format!(
66 "Pay to {username}@{}",
67 crate::lnurl::signed_domain(configured_domain)
68 )
69}
70
71#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
72#[allow(clippy::needless_pass_by_value)]
73impl BreezSdk {
74 pub async fn check_lightning_address_available(
80 &self,
81 req: CheckLightningAddressRequest,
82 ) -> Result<bool, SdkError> {
83 let Some(client) = &self.lnurl_server_client else {
84 return Err(SdkError::Generic(
85 "LNURL server is not configured".to_string(),
86 ));
87 };
88
89 let username = sanitize_username(&req.username);
90 let available = client.check_username_available(&username).await?;
91 Ok(available)
92 }
93
94 pub async fn get_lightning_address(&self) -> Result<Option<LightningAddressInfo>, SdkError> {
95 let cache = ObjectCacheRepository::new(self.storage.clone());
96 let cached = cache.fetch_lightning_address().await?;
97 if cached.is_none() && self.lnurl_server_client.is_some() {
98 return self.recover_lightning_address().await;
99 }
100 Ok(cached.flatten())
101 }
102
103 pub async fn register_lightning_address(
104 &self,
105 request: RegisterLightningAddressRequest,
106 ) -> Result<LightningAddressInfo, SdkError> {
107 let cache = ObjectCacheRepository::new(self.storage.clone());
108 let Some(client) = &self.lnurl_server_client else {
109 return Err(SdkError::Generic(
110 "LNURL server is not configured".to_string(),
111 ));
112 };
113
114 let username = sanitize_username(&request.username);
115
116 let description = match request.description {
117 Some(description) => description,
118 None => default_description(&username, client.domain()),
119 };
120
121 let params = crate::lnurl::RegisterLightningAddressRequest {
122 username: username.clone(),
123 description: description.clone(),
124 };
125
126 let response = client.register_lightning_address(¶ms).await?;
127 let address_info = LightningAddressInfo {
128 lightning_address: response.lightning_address,
129 description,
130 lnurl: LnurlInfo::new(response.lnurl),
131 username,
132 };
133 cache.save_lightning_address(&address_info, false).await?;
134 Ok(address_info)
135 }
136
137 pub async fn authorize_lightning_address_transfer(
143 &self,
144 request: AuthorizeTransferRequest,
145 ) -> Result<TransferAuthorization, SdkError> {
146 let cache = ObjectCacheRepository::new(self.storage.clone());
147 let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
148 return Err(SdkError::Generic(
149 "No lightning address registered to transfer".to_string(),
150 ));
151 };
152
153 let domain = address_domain(&address_info.lightning_address)?;
159
160 let self_pubkey = self.spark_wallet.get_identity_public_key().to_string();
161 let transferee_pubkey = normalized_pubkey(&request.transferee_pubkey)?;
162 let timestamp = try_now_secs()?;
163 let signature = self
164 .spark_wallet
165 .sign_message(&signed_message::transfer_from(
166 &domain,
167 &address_info.username,
168 &self_pubkey,
169 &transferee_pubkey,
170 timestamp,
171 ))
172 .await?;
173
174 Ok(TransferAuthorization {
175 username: address_info.username,
176 pubkey: self_pubkey,
177 signature: signature.serialize_der().to_lower_hex_string(),
178 domain,
179 timestamp,
180 })
181 }
182
183 pub async fn claim_lightning_address_transfer(
188 &self,
189 request: ClaimTransferRequest,
190 ) -> Result<LightningAddressInfo, SdkError> {
191 let cache = ObjectCacheRepository::new(self.storage.clone());
192 let Some(client) = &self.lnurl_server_client else {
193 return Err(SdkError::Generic(
194 "LNURL server is not configured".to_string(),
195 ));
196 };
197
198 require_configured_domain(
201 client.domain(),
202 &request.authorization.domain,
203 "the authorization",
204 )?;
205 if try_now_secs()?.abs_diff(request.authorization.timestamp) > signed_message::VALIDITY_SECS
210 {
211 return Err(SdkError::InvalidInput(
212 "authorization is expired or not yet valid; ask the current owner to \
213 authorize the transfer again"
214 .to_string(),
215 ));
216 }
217
218 let username = sanitize_username(&request.authorization.username);
219 let description = match request.description {
220 Some(description) => description,
221 None => default_description(&username, client.domain()),
222 };
223
224 let params = crate::lnurl::TransferLightningAddressRequest {
225 username: username.clone(),
226 description: description.clone(),
227 from_pubkey: normalized_pubkey(&request.authorization.pubkey)?,
228 from_signature: request.authorization.signature,
229 timestamp: request.authorization.timestamp,
230 };
231
232 let response = client.transfer_lightning_address(¶ms).await?;
233 let address_info = LightningAddressInfo {
234 lightning_address: response.lightning_address,
235 description,
236 lnurl: LnurlInfo::new(response.lnurl),
237 username,
238 };
239 cache.save_lightning_address(&address_info, false).await?;
240 Ok(address_info)
241 }
242
243 pub async fn delete_lightning_address(&self) -> Result<(), SdkError> {
250 let cache = ObjectCacheRepository::new(self.storage.clone());
251 let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
252 return Ok(());
253 };
254
255 let Some(client) = &self.lnurl_server_client else {
256 return Err(SdkError::Generic(
257 "LNURL server is not configured".to_string(),
258 ));
259 };
260
261 let params = crate::lnurl::UnregisterLightningAddressRequest {
262 username: address_info.username,
263 };
264
265 match client.unregister_lightning_address(¶ms).await {
266 Ok(()) => {}
267 Err(
273 e @ LnurlServerError::Network {
274 statuscode: 409, ..
275 },
276 ) => {
277 self.recover_lightning_address().await?;
278 return Err(e.into());
279 }
280 Err(e) => return Err(e.into()),
281 }
282
283 cache.delete_lightning_address(false).await?;
284 Ok(())
285 }
286}
287
288impl BreezSdk {
290 pub(super) async fn recover_lightning_address(
292 &self,
293 ) -> Result<Option<LightningAddressInfo>, SdkError> {
294 let cache = ObjectCacheRepository::new(self.storage.clone());
295
296 let Some(client) = &self.lnurl_server_client else {
297 return Err(SdkError::Generic(
298 "LNURL server is not configured".to_string(),
299 ));
300 };
301 let resp = client.recover_lightning_address().await?;
302
303 let result = if let Some(resp) = resp {
304 let address_info = resp.into();
305 cache.save_lightning_address(&address_info, true).await?;
306 Some(address_info)
307 } else {
308 cache.delete_lightning_address(true).await?;
309 None
310 };
311
312 Ok(result)
313 }
314}
315
316#[cfg(test)]
317#[cfg(feature = "sqlite")]
318mod tests {
319 use std::{path::PathBuf, sync::Arc};
320
321 use crate::{LightningAddressInfo, LnurlInfo, persist::sqlite::SqliteStorage};
322
323 use crate::persist::ObjectCacheRepository;
324
325 fn create_temp_dir(name: &str) -> PathBuf {
326 let mut path = std::env::temp_dir();
327 path.push(format!("breez-test-{}-{}", name, uuid::Uuid::new_v4()));
328 std::fs::create_dir_all(&path).unwrap();
329 path
330 }
331
332 fn create_temp_storage(name: &str) -> (Arc<SqliteStorage>, PathBuf) {
333 let dir = create_temp_dir(name);
334 let storage = SqliteStorage::new(&dir).expect("Failed to create storage");
335 (Arc::new(storage), dir)
336 }
337
338 fn sample_address_info() -> LightningAddressInfo {
339 LightningAddressInfo {
340 lightning_address: "test@example.com".to_string(),
341 username: "test".to_string(),
342 description: "Test address".to_string(),
343 lnurl: LnurlInfo::new("https://example.com/.well-known/lnurlp/test".to_string()),
344 }
345 }
346
347 #[tokio::test]
348 async fn test_fetch_returns_none_when_never_recovered() {
349 let (storage, _dir) = create_temp_storage("never_recovered");
350 let cache = ObjectCacheRepository::new(storage as Arc<_>);
351
352 let result = cache.fetch_lightning_address().await.unwrap();
354 assert!(result.is_none());
355 }
356
357 #[tokio::test]
358 async fn test_fetch_returns_some_none_after_delete() {
359 let (storage, _dir) = create_temp_storage("after_delete");
360 let cache = ObjectCacheRepository::new(storage as Arc<_>);
361
362 cache
364 .save_lightning_address(&sample_address_info(), false)
365 .await
366 .unwrap();
367 cache.delete_lightning_address(false).await.unwrap();
368
369 let result = cache.fetch_lightning_address().await.unwrap();
371 assert!(
372 matches!(result, Some(None)),
373 "Expected Some(None) after delete"
374 );
375 }
376
377 #[tokio::test]
378 async fn test_fetch_returns_some_some_after_save() {
379 let (storage, _dir) = create_temp_storage("after_save");
380 let cache = ObjectCacheRepository::new(storage as Arc<_>);
381
382 cache
383 .save_lightning_address(&sample_address_info(), false)
384 .await
385 .unwrap();
386
387 let result = cache.fetch_lightning_address().await.unwrap();
389 let info = result
390 .flatten()
391 .expect("Expected Some(Some(info)) after save");
392 assert_eq!(info.lightning_address, "test@example.com");
393 }
394}
395
396#[cfg(test)]
397mod domain_tests {
398 use super::{address_domain, require_configured_domain};
399
400 #[test]
403 fn the_domain_comes_from_the_cached_address() {
404 assert_eq!(address_domain("alice@example.com").unwrap(), "example.com");
405 assert_eq!(address_domain("alice@Example.COM").unwrap(), "example.com");
407 assert_eq!(address_domain("a@b@example.com").unwrap(), "example.com");
410 assert!(address_domain("not-an-address").is_err());
411 }
412
413 #[test]
417 fn a_configured_domain_matches_however_it_was_written() {
418 for configured in [
419 "example.com",
420 "https://example.com",
421 "https://example.com/",
422 "https://example.com/lnurl",
423 "https://user:pass@example.com",
424 "https://EXAMPLE.com",
425 ] {
426 assert!(
427 require_configured_domain(configured, "example.com", "the authorization").is_ok(),
428 "{configured}"
429 );
430 }
431 }
432
433 #[test]
436 fn a_domain_that_is_not_the_configured_one_names_both() {
437 let error = require_configured_domain(
438 "https://example.com",
439 "other.example.com",
440 "the authorization",
441 )
442 .unwrap_err()
443 .to_string();
444 for expected in ["the authorization", "other.example.com", "example.com"] {
445 assert!(error.contains(expected), "{error}");
446 }
447 }
448}