Skip to main content

breez_sdk_spark/sdk/
lightning_address.rs

1use std::str::FromStr;
2use std::sync::Arc;
3
4use bitcoin::hex::DisplayHex;
5use lnurl_models::{sanitize_username, signed_message};
6use tracing::info;
7
8use crate::utils::time::try_now_secs;
9use crate::{
10    AuthorizeTransferRequest, CheckLightningAddressRequest, ClaimTransferRequest,
11    LightningAddressInfo, LnurlInfo, RegisterLightningAddressRequest, Storage,
12    TransferAuthorization,
13    error::SdkError,
14    lnurl::LnurlServerError,
15    persist::{LIGHTNING_ADDRESS_KEY, ObjectCacheRepository},
16};
17
18use super::BreezSdk;
19
20/// Lowercase compressed hex, the form the server rebuilds the signed message
21/// with. A caller-supplied pubkey that differs only in case or encoding would
22/// otherwise produce a message the server never reconstructs, and the only
23/// symptom would be "invalid signature".
24fn normalized_pubkey(pubkey: &str) -> Result<String, SdkError> {
25    bitcoin::secp256k1::PublicKey::from_str(pubkey)
26        .map(|pubkey| pubkey.to_string())
27        .map_err(|_| SdkError::InvalidInput(format!("'{pubkey}' is not a valid public key")))
28}
29
30/// The domain a `{username}@{domain}` lightning address lives on.
31///
32/// The address is the server's own record of the domain it resolved when the
33/// registration was made, so it names where the registration lives even after
34/// this SDK is pointed elsewhere.
35fn address_domain(lightning_address: &str) -> Result<String, SdkError> {
36    lightning_address
37        .rsplit_once('@')
38        .map(|(_, domain)| domain.to_ascii_lowercase())
39        .ok_or_else(|| {
40            SdkError::Generic(format!(
41                "cached lightning address '{lightning_address}' has no domain"
42            ))
43        })
44}
45
46/// Rejects a domain that is not the one this SDK talks to, naming both so the
47/// failure says which server that is.
48///
49/// This belongs to the side that makes the request: the domain its server
50/// resolves is what a signature has to match. The side producing a signature for
51/// someone else to submit has no such constraint.
52fn require_configured_domain(
53    configured_domain: &str,
54    domain: &str,
55    subject: &str,
56) -> Result<(), SdkError> {
57    let configured = crate::lnurl::signed_domain(configured_domain);
58    if domain != configured {
59        return Err(SdkError::InvalidInput(format!(
60            "{subject} names domain '{domain}', but this SDK is configured for '{configured}'"
61        )));
62    }
63    Ok(())
64}
65
66/// Clears a cached address that is not on the configured domain, so that the
67/// SDK recovers the address from that domain.
68async fn clear_address_on_other_domain(
69    storage: &Arc<dyn Storage>,
70    configured_domain: &str,
71) -> Result<(), SdkError> {
72    let cache = ObjectCacheRepository::new(Arc::clone(storage));
73    let Some(Some(address_info)) = cache.fetch_lightning_address().await? else {
74        return Ok(());
75    };
76    let configured = crate::lnurl::signed_domain(configured_domain);
77    if address_domain(&address_info.lightning_address).is_ok_and(|domain| domain == configured) {
78        return Ok(());
79    }
80    storage
81        .delete_cached_item(LIGHTNING_ADDRESS_KEY.to_string())
82        .await?;
83    info!(
84        "Cleared the cached lightning address {}, which is not on {configured}",
85        address_info.lightning_address
86    );
87    Ok(())
88}
89
90/// The description the LNURL server shows for an address. A server that does
91/// not return it shows the description it was sent.
92fn shown_description(returned: Option<String>, sent: Option<String>) -> String {
93    returned.or(sent).unwrap_or_default()
94}
95
96#[macros::instance_span]
97#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
98#[allow(clippy::needless_pass_by_value)]
99impl BreezSdk {
100    /// Check whether a username is free for this wallet to register.
101    ///
102    /// The check is signed with the wallet's identity key, so every call costs
103    /// a signing operation and a server round trip: run it when the user
104    /// finishes typing, not on every keystroke.
105    pub async fn check_lightning_address_available(
106        &self,
107        req: CheckLightningAddressRequest,
108    ) -> Result<bool, SdkError> {
109        let Some(client) = &self.lnurl_server_client else {
110            return Err(SdkError::Generic(
111                "LNURL server is not configured".to_string(),
112            ));
113        };
114
115        let username = sanitize_username(&req.username);
116        let available = client.check_username_available(&username).await?;
117        Ok(available)
118    }
119
120    pub async fn get_lightning_address(&self) -> Result<Option<LightningAddressInfo>, SdkError> {
121        let cache = ObjectCacheRepository::new(self.storage.clone());
122        let cached = cache.fetch_lightning_address().await?;
123        if cached.is_none() && self.lnurl_server_client.is_some() {
124            return self.recover_lightning_address().await;
125        }
126        Ok(cached.flatten())
127    }
128
129    pub async fn register_lightning_address(
130        &self,
131        request: RegisterLightningAddressRequest,
132    ) -> Result<LightningAddressInfo, SdkError> {
133        let cache = ObjectCacheRepository::new(self.storage.clone());
134        let Some(client) = &self.lnurl_server_client else {
135            return Err(SdkError::Generic(
136                "LNURL server is not configured".to_string(),
137            ));
138        };
139
140        let username = sanitize_username(&request.username);
141
142        let params = crate::lnurl::RegisterLightningAddressRequest {
143            username: username.clone(),
144            description: request.description,
145        };
146
147        let response = client.register_lightning_address(&params).await?;
148        let address_info = LightningAddressInfo {
149            lightning_address: response.lightning_address,
150            description: shown_description(response.description, params.description),
151            lnurl: LnurlInfo::new(response.lnurl),
152            username,
153        };
154        cache.save_lightning_address(&address_info, false).await?;
155        Ok(address_info)
156    }
157
158    /// Authorize transferring the current owner's registered lightning address
159    /// username to `request.transferee_pubkey`. Returns a
160    /// [`TransferAuthorization`] to hand to the new owner, who
161    /// claims it via [`BreezSdk::claim_lightning_address_transfer`].
162    /// Errors if the current owner has no lightning address registered.
163    pub async fn authorize_lightning_address_transfer(
164        &self,
165        request: AuthorizeTransferRequest,
166    ) -> Result<TransferAuthorization, SdkError> {
167        let cache = ObjectCacheRepository::new(self.storage.clone());
168        let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
169            return Err(SdkError::Generic(
170                "No lightning address registered to transfer".to_string(),
171            ));
172        };
173
174        // The domain the address is registered on, not this SDK's configured
175        // one. The transferee submits the transfer, so what the signature has to
176        // cover is the domain their server resolves, and this SDK's own
177        // configuration is not part of that: an address cached from a domain it
178        // no longer points at is still a registration it can hand over.
179        let domain = address_domain(&address_info.lightning_address)?;
180
181        let self_pubkey = self.spark_wallet.get_identity_public_key().to_string();
182        let transferee_pubkey = normalized_pubkey(&request.transferee_pubkey)?;
183        let timestamp = try_now_secs()?;
184        let signature = self
185            .spark_wallet
186            .sign_message(&signed_message::transfer_from(
187                &domain,
188                &address_info.username,
189                &self_pubkey,
190                &transferee_pubkey,
191                timestamp,
192            ))
193            .await?;
194
195        Ok(TransferAuthorization {
196            username: address_info.username,
197            pubkey: self_pubkey,
198            signature: signature.serialize_der().to_lower_hex_string(),
199            domain,
200            timestamp,
201        })
202    }
203
204    /// Claim a lightning address username handed over by its current owner,
205    /// using the [`TransferAuthorization`] from
206    /// [`BreezSdk::authorize_lightning_address_transfer`]. Completes the
207    /// takeover and returns the newly-owned address.
208    pub async fn claim_lightning_address_transfer(
209        &self,
210        request: ClaimTransferRequest,
211    ) -> Result<LightningAddressInfo, SdkError> {
212        let cache = ObjectCacheRepository::new(self.storage.clone());
213        let Some(client) = &self.lnurl_server_client else {
214            return Err(SdkError::Generic(
215                "LNURL server is not configured".to_string(),
216            ));
217        };
218
219        // Checked before the round trip so the failure names both domains, and
220        // says which one this SDK is configured for.
221        require_configured_domain(
222            client.domain(),
223            &request.authorization.domain,
224            "the authorization",
225        )?;
226        // The window the server enforces, applied here so an authorization the
227        // transferee sat on fails saying so rather than as a generic rejection.
228        // Deliberately short: nothing revokes an authorization, so expiry is the
229        // only thing that takes one back.
230        if try_now_secs()?.abs_diff(request.authorization.timestamp) > signed_message::VALIDITY_SECS
231        {
232            return Err(SdkError::InvalidInput(
233                "authorization is expired or not yet valid; ask the current owner to \
234                 authorize the transfer again"
235                    .to_string(),
236            ));
237        }
238
239        let username = sanitize_username(&request.authorization.username);
240
241        let params = crate::lnurl::TransferLightningAddressRequest {
242            username: username.clone(),
243            description: request.description,
244            from_pubkey: normalized_pubkey(&request.authorization.pubkey)?,
245            from_signature: request.authorization.signature,
246            timestamp: request.authorization.timestamp,
247        };
248
249        let response = client.transfer_lightning_address(&params).await?;
250        let address_info = LightningAddressInfo {
251            lightning_address: response.lightning_address,
252            description: shown_description(response.description, params.description),
253            lnurl: LnurlInfo::new(response.lnurl),
254            username,
255        };
256        cache.save_lightning_address(&address_info, false).await?;
257        Ok(address_info)
258    }
259
260    /// Give up this wallet's lightning address.
261    ///
262    /// The server holds the address for this wallet afterwards: while the hold
263    /// stands only this wallet can register it, so payers who kept the old
264    /// address are not redirected to someone else. How long a hold stands is
265    /// the server's policy.
266    pub async fn delete_lightning_address(&self) -> Result<(), SdkError> {
267        let cache = ObjectCacheRepository::new(self.storage.clone());
268        let Some(address_info) = cache.fetch_lightning_address().await?.flatten() else {
269            return Ok(());
270        };
271
272        let Some(client) = &self.lnurl_server_client else {
273            return Err(SdkError::Generic(
274                "LNURL server is not configured".to_string(),
275            ));
276        };
277
278        let params = crate::lnurl::UnregisterLightningAddressRequest {
279            username: address_info.username,
280        };
281
282        match client.unregister_lightning_address(&params).await {
283            Ok(()) => {}
284            // A 409 is either a name this wallet no longer holds (another
285            // device re-registered under the same identity key) or a statement
286            // the server already acted on. Resync settles both: it re-caches an
287            // address that is still there and clears one that is gone, so a
288            // retry either signs the real address or short-circuits.
289            Err(
290                e @ LnurlServerError::Network {
291                    statuscode: 409, ..
292                },
293            ) => {
294                self.recover_lightning_address().await?;
295                return Err(e.into());
296            }
297            Err(e) => return Err(e.into()),
298        }
299
300        cache.delete_lightning_address(false).await?;
301        Ok(())
302    }
303}
304
305// Private lightning address methods
306impl BreezSdk {
307    pub(super) async fn clear_lightning_address_on_other_domain(&self) -> Result<(), SdkError> {
308        let Some(client) = &self.lnurl_server_client else {
309            return Ok(());
310        };
311        clear_address_on_other_domain(&self.storage, client.domain()).await
312    }
313
314    /// Attempts to recover a lightning address from the lnurl server.
315    pub(super) async fn recover_lightning_address(
316        &self,
317    ) -> Result<Option<LightningAddressInfo>, SdkError> {
318        let cache = ObjectCacheRepository::new(self.storage.clone());
319
320        let Some(client) = &self.lnurl_server_client else {
321            return Err(SdkError::Generic(
322                "LNURL server is not configured".to_string(),
323            ));
324        };
325        let resp = client.recover_lightning_address().await?;
326
327        let result = if let Some(resp) = resp {
328            let address_info = resp.into();
329            cache.save_lightning_address(&address_info, true).await?;
330            Some(address_info)
331        } else {
332            cache.delete_lightning_address(true).await?;
333            None
334        };
335
336        Ok(result)
337    }
338}
339
340#[cfg(test)]
341#[cfg(feature = "sqlite")]
342mod tests {
343    use std::{path::PathBuf, sync::Arc};
344
345    use crate::{LightningAddressInfo, LnurlInfo, Storage, persist::sqlite::SqliteStorage};
346
347    use crate::persist::ObjectCacheRepository;
348
349    use super::clear_address_on_other_domain;
350
351    fn create_temp_dir(name: &str) -> PathBuf {
352        let mut path = std::env::temp_dir();
353        path.push(format!("breez-test-{}-{}", name, uuid::Uuid::new_v4()));
354        std::fs::create_dir_all(&path).unwrap();
355        path
356    }
357
358    fn create_temp_storage(name: &str) -> (Arc<SqliteStorage>, PathBuf) {
359        let dir = create_temp_dir(name);
360        let storage = SqliteStorage::new(&dir).expect("Failed to create storage");
361        (Arc::new(storage), dir)
362    }
363
364    fn sample_address_info() -> LightningAddressInfo {
365        LightningAddressInfo {
366            lightning_address: "test@example.com".to_string(),
367            username: "test".to_string(),
368            description: "Test address".to_string(),
369            lnurl: LnurlInfo::new("https://example.com/.well-known/lnurlp/test".to_string()),
370        }
371    }
372
373    #[tokio::test]
374    async fn test_fetch_returns_none_when_never_recovered() {
375        let (storage, _dir) = create_temp_storage("never_recovered");
376        let cache = ObjectCacheRepository::new(storage as Arc<_>);
377
378        // Key absent -> None (never recovered)
379        let result = cache.fetch_lightning_address().await.unwrap();
380        assert!(result.is_none());
381    }
382
383    #[tokio::test]
384    async fn test_fetch_returns_some_none_after_delete() {
385        let (storage, _dir) = create_temp_storage("after_delete");
386        let cache = ObjectCacheRepository::new(storage as Arc<_>);
387
388        // Save an address, then delete it
389        cache
390            .save_lightning_address(&sample_address_info(), false)
391            .await
392            .unwrap();
393        cache.delete_lightning_address(false).await.unwrap();
394
395        // Key present, value null -> Some(None) (recovered, no address)
396        let result = cache.fetch_lightning_address().await.unwrap();
397        assert!(
398            matches!(result, Some(None)),
399            "Expected Some(None) after delete"
400        );
401    }
402
403    #[tokio::test]
404    async fn test_fetch_returns_some_some_after_save() {
405        let (storage, _dir) = create_temp_storage("after_save");
406        let cache = ObjectCacheRepository::new(storage as Arc<_>);
407
408        cache
409            .save_lightning_address(&sample_address_info(), false)
410            .await
411            .unwrap();
412
413        // Key present, value non-null -> Some(Some(info))
414        let result = cache.fetch_lightning_address().await.unwrap();
415        let info = result
416            .flatten()
417            .expect("Expected Some(Some(info)) after save");
418        assert_eq!(info.lightning_address, "test@example.com");
419    }
420
421    #[tokio::test]
422    async fn an_address_on_another_domain_is_cleared() {
423        let (storage, _dir) = create_temp_storage("other_domain");
424        let storage: Arc<dyn Storage> = storage;
425        let cache = ObjectCacheRepository::new(Arc::clone(&storage));
426        cache
427            .save_lightning_address(&sample_address_info(), true)
428            .await
429            .unwrap();
430
431        clear_address_on_other_domain(&storage, "https://other.example.com")
432            .await
433            .unwrap();
434
435        assert!(cache.fetch_lightning_address().await.unwrap().is_none());
436    }
437
438    #[tokio::test]
439    async fn an_address_on_the_configured_domain_stays() {
440        let (storage, _dir) = create_temp_storage("configured_domain");
441        let storage: Arc<dyn Storage> = storage;
442        let cache = ObjectCacheRepository::new(Arc::clone(&storage));
443        let address = LightningAddressInfo {
444            lightning_address: "test@127.0.0.1:8080".to_string(),
445            ..sample_address_info()
446        };
447        cache.save_lightning_address(&address, true).await.unwrap();
448
449        clear_address_on_other_domain(&storage, "http://127.0.0.1:8080/")
450            .await
451            .unwrap();
452
453        assert_eq!(
454            cache.fetch_lightning_address().await.unwrap(),
455            Some(Some(address))
456        );
457    }
458
459    /// A cached record that the wallet has no address names no domain to
460    /// compare.
461    #[tokio::test]
462    async fn a_record_of_no_address_stays() {
463        let (storage, _dir) = create_temp_storage("no_address");
464        let storage: Arc<dyn Storage> = storage;
465        let cache = ObjectCacheRepository::new(Arc::clone(&storage));
466        cache.delete_lightning_address(true).await.unwrap();
467
468        clear_address_on_other_domain(&storage, "https://other.example.com")
469            .await
470            .unwrap();
471
472        assert!(matches!(
473            cache.fetch_lightning_address().await.unwrap(),
474            Some(None)
475        ));
476    }
477}
478
479#[cfg(test)]
480mod domain_tests {
481    use super::{address_domain, require_configured_domain};
482
483    /// Read from the address itself, so it names the domain the address is
484    /// actually registered on rather than whatever the SDK is configured with.
485    #[test]
486    fn the_domain_comes_from_the_cached_address() {
487        assert_eq!(address_domain("alice@example.com").unwrap(), "example.com");
488        // Lowercased to match what the server resolves and lowercases.
489        assert_eq!(address_domain("alice@Example.COM").unwrap(), "example.com");
490        // A username may itself contain '@' in principle, so the split takes
491        // the last one.
492        assert_eq!(address_domain("a@b@example.com").unwrap(), "example.com");
493        assert!(address_domain("not-an-address").is_err());
494    }
495
496    /// Compared against the same normalization the signed message uses, so a
497    /// scheme, a mount path or a trailing slash on the configured value is not
498    /// a mismatch.
499    #[test]
500    fn a_configured_domain_matches_however_it_was_written() {
501        for configured in [
502            "example.com",
503            "https://example.com",
504            "https://example.com/",
505            "https://example.com/lnurl",
506            "https://user:pass@example.com",
507            "https://EXAMPLE.com",
508        ] {
509            assert!(
510                require_configured_domain(configured, "example.com", "the authorization").is_ok(),
511                "{configured}"
512            );
513        }
514    }
515
516    /// Both domains are named, since the caller has to see which one this SDK
517    /// talks to before it can tell which side is stale.
518    #[test]
519    fn a_domain_that_is_not_the_configured_one_names_both() {
520        let error = require_configured_domain(
521            "https://example.com",
522            "other.example.com",
523            "the authorization",
524        )
525        .unwrap_err()
526        .to_string();
527        for expected in ["the authorization", "other.example.com", "example.com"] {
528            assert!(error.contains(expected), "{error}");
529        }
530    }
531}