1use std::str::FromStr;
2
3use bitcoin::hex::DisplayHex;
4use lnurl_models::{sanitize_username, signed_message};
5use platform_utils::time::{SystemTime, UNIX_EPOCH};
6
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 now_secs() -> Result<u64, SdkError> {
16 SystemTime::now()
17 .duration_since(UNIX_EPOCH)
18 .map(|elapsed| elapsed.as_secs())
19 .map_err(|_| SdkError::Generic("system clock is before the Unix epoch".to_string()))
20}
21
22fn normalized_pubkey(pubkey: &str) -> Result<String, SdkError> {
27 bitcoin::secp256k1::PublicKey::from_str(pubkey)
28 .map(|pubkey| pubkey.to_string())
29 .map_err(|_| SdkError::InvalidInput(format!("'{pubkey}' is not a valid public key")))
30}
31
32fn address_domain(lightning_address: &str) -> Result<String, SdkError> {
38 lightning_address
39 .rsplit_once('@')
40 .map(|(_, domain)| domain.to_ascii_lowercase())
41 .ok_or_else(|| {
42 SdkError::Generic(format!(
43 "cached lightning address '{lightning_address}' has no domain"
44 ))
45 })
46}
47
48fn require_configured_domain(
55 configured_domain: &str,
56 domain: &str,
57 subject: &str,
58) -> Result<(), SdkError> {
59 let configured = crate::lnurl::signed_domain(configured_domain);
60 if domain != configured {
61 return Err(SdkError::InvalidInput(format!(
62 "{subject} names domain '{domain}', but this SDK is configured for '{configured}'"
63 )));
64 }
65 Ok(())
66}
67
68fn default_description(username: &str, configured_domain: &str) -> String {
72 format!(
73 "Pay to {username}@{}",
74 crate::lnurl::signed_domain(configured_domain)
75 )
76}
77
78#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
79#[allow(clippy::needless_pass_by_value)]
80impl BreezSdk {
81 pub async fn check_lightning_address_available(
87 &self,
88 req: CheckLightningAddressRequest,
89 ) -> Result<bool, SdkError> {
90 let Some(client) = &self.lnurl_server_client else {
91 return Err(SdkError::Generic(
92 "LNURL server is not configured".to_string(),
93 ));
94 };
95
96 let username = sanitize_username(&req.username);
97 let available = client.check_username_available(&username).await?;
98 Ok(available)
99 }
100
101 pub async fn get_lightning_address(&self) -> Result<Option<LightningAddressInfo>, SdkError> {
102 let cache = ObjectCacheRepository::new(self.storage.clone());
103 let cached = cache.fetch_lightning_address().await?;
104 if cached.is_none() && self.lnurl_server_client.is_some() {
105 return self.recover_lightning_address().await;
106 }
107 Ok(cached.flatten())
108 }
109
110 pub async fn register_lightning_address(
111 &self,
112 request: RegisterLightningAddressRequest,
113 ) -> Result<LightningAddressInfo, SdkError> {
114 let cache = ObjectCacheRepository::new(self.storage.clone());
115 let Some(client) = &self.lnurl_server_client else {
116 return Err(SdkError::Generic(
117 "LNURL server is not configured".to_string(),
118 ));
119 };
120
121 let username = sanitize_username(&request.username);
122
123 let description = match request.description {
124 Some(description) => description,
125 None => default_description(&username, client.domain()),
126 };
127
128 let params = crate::lnurl::RegisterLightningAddressRequest {
129 username: username.clone(),
130 description: description.clone(),
131 };
132
133 let response = client.register_lightning_address(¶ms).await?;
134 let address_info = LightningAddressInfo {
135 lightning_address: response.lightning_address,
136 description,
137 lnurl: LnurlInfo::new(response.lnurl),
138 username,
139 };
140 cache.save_lightning_address(&address_info, false).await?;
141 Ok(address_info)
142 }
143
144 pub async fn authorize_lightning_address_transfer(
150 &self,
151 request: AuthorizeTransferRequest,
152 ) -> Result<TransferAuthorization, SdkError> {
153 let cache = ObjectCacheRepository::new(self.storage.clone());
154 let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
155 return Err(SdkError::Generic(
156 "No lightning address registered to transfer".to_string(),
157 ));
158 };
159
160 let domain = address_domain(&address_info.lightning_address)?;
166
167 let self_pubkey = self.spark_wallet.get_identity_public_key().to_string();
168 let transferee_pubkey = normalized_pubkey(&request.transferee_pubkey)?;
169 let timestamp = now_secs()?;
170 let signature = self
171 .spark_wallet
172 .sign_message(&signed_message::transfer_from(
173 &domain,
174 &address_info.username,
175 &self_pubkey,
176 &transferee_pubkey,
177 timestamp,
178 ))
179 .await?;
180
181 Ok(TransferAuthorization {
182 username: address_info.username,
183 pubkey: self_pubkey,
184 signature: signature.serialize_der().to_lower_hex_string(),
185 domain,
186 timestamp,
187 })
188 }
189
190 pub async fn claim_lightning_address_transfer(
195 &self,
196 request: ClaimTransferRequest,
197 ) -> Result<LightningAddressInfo, SdkError> {
198 let cache = ObjectCacheRepository::new(self.storage.clone());
199 let Some(client) = &self.lnurl_server_client else {
200 return Err(SdkError::Generic(
201 "LNURL server is not configured".to_string(),
202 ));
203 };
204
205 require_configured_domain(
208 client.domain(),
209 &request.authorization.domain,
210 "the authorization",
211 )?;
212 if now_secs()?.abs_diff(request.authorization.timestamp) > signed_message::VALIDITY_SECS {
217 return Err(SdkError::InvalidInput(
218 "authorization is expired or not yet valid; ask the current owner to \
219 authorize the transfer again"
220 .to_string(),
221 ));
222 }
223
224 let username = sanitize_username(&request.authorization.username);
225 let description = match request.description {
226 Some(description) => description,
227 None => default_description(&username, client.domain()),
228 };
229
230 let params = crate::lnurl::TransferLightningAddressRequest {
231 username: username.clone(),
232 description: description.clone(),
233 from_pubkey: normalized_pubkey(&request.authorization.pubkey)?,
234 from_signature: request.authorization.signature,
235 timestamp: request.authorization.timestamp,
236 };
237
238 let response = client.transfer_lightning_address(¶ms).await?;
239 let address_info = LightningAddressInfo {
240 lightning_address: response.lightning_address,
241 description,
242 lnurl: LnurlInfo::new(response.lnurl),
243 username,
244 };
245 cache.save_lightning_address(&address_info, false).await?;
246 Ok(address_info)
247 }
248
249 pub async fn delete_lightning_address(&self) -> Result<(), SdkError> {
256 let cache = ObjectCacheRepository::new(self.storage.clone());
257 let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
258 return Ok(());
259 };
260
261 let Some(client) = &self.lnurl_server_client else {
262 return Err(SdkError::Generic(
263 "LNURL server is not configured".to_string(),
264 ));
265 };
266
267 let params = crate::lnurl::UnregisterLightningAddressRequest {
268 username: address_info.username,
269 };
270
271 match client.unregister_lightning_address(¶ms).await {
272 Ok(()) => {}
273 Err(
279 e @ LnurlServerError::Network {
280 statuscode: 409, ..
281 },
282 ) => {
283 self.recover_lightning_address().await?;
284 return Err(e.into());
285 }
286 Err(e) => return Err(e.into()),
287 }
288
289 cache.delete_lightning_address(false).await?;
290 Ok(())
291 }
292}
293
294impl BreezSdk {
296 pub(super) async fn recover_lightning_address(
298 &self,
299 ) -> Result<Option<LightningAddressInfo>, SdkError> {
300 let cache = ObjectCacheRepository::new(self.storage.clone());
301
302 let Some(client) = &self.lnurl_server_client else {
303 return Err(SdkError::Generic(
304 "LNURL server is not configured".to_string(),
305 ));
306 };
307 let resp = client.recover_lightning_address().await?;
308
309 let result = if let Some(resp) = resp {
310 let address_info = resp.into();
311 cache.save_lightning_address(&address_info, true).await?;
312 Some(address_info)
313 } else {
314 cache.delete_lightning_address(true).await?;
315 None
316 };
317
318 Ok(result)
319 }
320}
321
322#[cfg(test)]
323#[cfg(feature = "sqlite")]
324mod tests {
325 use std::{path::PathBuf, sync::Arc};
326
327 use crate::{LightningAddressInfo, LnurlInfo, persist::sqlite::SqliteStorage};
328
329 use crate::persist::ObjectCacheRepository;
330
331 fn create_temp_dir(name: &str) -> PathBuf {
332 let mut path = std::env::temp_dir();
333 path.push(format!("breez-test-{}-{}", name, uuid::Uuid::new_v4()));
334 std::fs::create_dir_all(&path).unwrap();
335 path
336 }
337
338 fn create_temp_storage(name: &str) -> (Arc<SqliteStorage>, PathBuf) {
339 let dir = create_temp_dir(name);
340 let storage = SqliteStorage::new(&dir).expect("Failed to create storage");
341 (Arc::new(storage), dir)
342 }
343
344 fn sample_address_info() -> LightningAddressInfo {
345 LightningAddressInfo {
346 lightning_address: "test@example.com".to_string(),
347 username: "test".to_string(),
348 description: "Test address".to_string(),
349 lnurl: LnurlInfo::new("https://example.com/.well-known/lnurlp/test".to_string()),
350 }
351 }
352
353 #[tokio::test]
354 async fn test_fetch_returns_none_when_never_recovered() {
355 let (storage, _dir) = create_temp_storage("never_recovered");
356 let cache = ObjectCacheRepository::new(storage as Arc<_>);
357
358 let result = cache.fetch_lightning_address().await.unwrap();
360 assert!(result.is_none());
361 }
362
363 #[tokio::test]
364 async fn test_fetch_returns_some_none_after_delete() {
365 let (storage, _dir) = create_temp_storage("after_delete");
366 let cache = ObjectCacheRepository::new(storage as Arc<_>);
367
368 cache
370 .save_lightning_address(&sample_address_info(), false)
371 .await
372 .unwrap();
373 cache.delete_lightning_address(false).await.unwrap();
374
375 let result = cache.fetch_lightning_address().await.unwrap();
377 assert!(
378 matches!(result, Some(None)),
379 "Expected Some(None) after delete"
380 );
381 }
382
383 #[tokio::test]
384 async fn test_fetch_returns_some_some_after_save() {
385 let (storage, _dir) = create_temp_storage("after_save");
386 let cache = ObjectCacheRepository::new(storage as Arc<_>);
387
388 cache
389 .save_lightning_address(&sample_address_info(), false)
390 .await
391 .unwrap();
392
393 let result = cache.fetch_lightning_address().await.unwrap();
395 let info = result
396 .flatten()
397 .expect("Expected Some(Some(info)) after save");
398 assert_eq!(info.lightning_address, "test@example.com");
399 }
400}
401
402#[cfg(test)]
403mod domain_tests {
404 use super::{address_domain, require_configured_domain};
405
406 #[test]
409 fn the_domain_comes_from_the_cached_address() {
410 assert_eq!(address_domain("alice@example.com").unwrap(), "example.com");
411 assert_eq!(address_domain("alice@Example.COM").unwrap(), "example.com");
413 assert_eq!(address_domain("a@b@example.com").unwrap(), "example.com");
416 assert!(address_domain("not-an-address").is_err());
417 }
418
419 #[test]
423 fn a_configured_domain_matches_however_it_was_written() {
424 for configured in [
425 "example.com",
426 "https://example.com",
427 "https://example.com/",
428 "https://example.com/lnurl",
429 "https://user:pass@example.com",
430 "https://EXAMPLE.com",
431 ] {
432 assert!(
433 require_configured_domain(configured, "example.com", "the authorization").is_ok(),
434 "{configured}"
435 );
436 }
437 }
438
439 #[test]
442 fn a_domain_that_is_not_the_configured_one_names_both() {
443 let error = require_configured_domain(
444 "https://example.com",
445 "other.example.com",
446 "the authorization",
447 )
448 .unwrap_err()
449 .to_string();
450 for expected in ["the authorization", "other.example.com", "example.com"] {
451 assert!(error.contains(expected), "{error}");
452 }
453 }
454}