Skip to main content

breez_sdk_spark/sdk/
lightning_address.rs

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
22/// Lowercase compressed hex, the form the server rebuilds the signed message
23/// with. A caller-supplied pubkey that differs only in case or encoding would
24/// otherwise produce a message the server never reconstructs, and the only
25/// symptom would be "invalid signature".
26fn 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
32/// The domain a `{username}@{domain}` lightning address lives on.
33///
34/// The address is the server's own record of the domain it resolved when the
35/// registration was made, so it names where the registration lives even after
36/// this SDK is pointed elsewhere.
37fn 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
48/// Rejects a domain that is not the one this SDK talks to, naming both so the
49/// failure says which server that is.
50///
51/// This belongs to the side that makes the request: the domain its server
52/// resolves is what a signature has to match. The side producing a signature for
53/// someone else to submit has no such constraint.
54fn 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
68/// Names the address as a payer would type it, so the configured domain is
69/// reduced to the authority the server registers the address under rather than
70/// interpolated whole.
71fn 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    /// Check whether a username is free for this wallet to register.
82    ///
83    /// The check is signed with the wallet's identity key, so every call costs
84    /// a signing operation and a server round trip: run it when the user
85    /// finishes typing, not on every keystroke.
86    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(&params).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    /// Authorize transferring the current owner's registered lightning address
145    /// username to `request.transferee_pubkey`. Returns a
146    /// [`TransferAuthorization`] to hand to the new owner, who
147    /// claims it via [`BreezSdk::claim_lightning_address_transfer`].
148    /// Errors if the current owner has no lightning address registered.
149    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        // The domain the address is registered on, not this SDK's configured
161        // one. The transferee submits the transfer, so what the signature has to
162        // cover is the domain their server resolves, and this SDK's own
163        // configuration is not part of that: an address cached from a domain it
164        // no longer points at is still a registration it can hand over.
165        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    /// Claim a lightning address username handed over by its current owner,
191    /// using the [`TransferAuthorization`] from
192    /// [`BreezSdk::authorize_lightning_address_transfer`]. Completes the
193    /// takeover and returns the newly-owned address.
194    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        // Checked before the round trip so the failure names both domains, and
206        // says which one this SDK is configured for.
207        require_configured_domain(
208            client.domain(),
209            &request.authorization.domain,
210            "the authorization",
211        )?;
212        // The window the server enforces, applied here so an authorization the
213        // transferee sat on fails saying so rather than as a generic rejection.
214        // Deliberately short: nothing revokes an authorization, so expiry is the
215        // only thing that takes one back.
216        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(&params).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    /// Give up this wallet's lightning address.
250    ///
251    /// The server holds the address for this wallet afterwards: while the hold
252    /// stands only this wallet can register it, so payers who kept the old
253    /// address are not redirected to someone else. How long a hold stands is
254    /// the server's policy.
255    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(&params).await {
272            Ok(()) => {}
273            // A 409 is either a name this wallet no longer holds (another
274            // device re-registered under the same identity key) or a statement
275            // the server already acted on. Resync settles both: it re-caches an
276            // address that is still there and clears one that is gone, so a
277            // retry either signs the real address or short-circuits.
278            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
294// Private lightning address methods
295impl BreezSdk {
296    /// Attempts to recover a lightning address from the lnurl server.
297    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        // Key absent -> None (never recovered)
359        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        // Save an address, then delete it
369        cache
370            .save_lightning_address(&sample_address_info(), false)
371            .await
372            .unwrap();
373        cache.delete_lightning_address(false).await.unwrap();
374
375        // Key present, value null -> Some(None) (recovered, no address)
376        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        // Key present, value non-null -> Some(Some(info))
394        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    /// Read from the address itself, so it names the domain the address is
407    /// actually registered on rather than whatever the SDK is configured with.
408    #[test]
409    fn the_domain_comes_from_the_cached_address() {
410        assert_eq!(address_domain("alice@example.com").unwrap(), "example.com");
411        // Lowercased to match what the server resolves and lowercases.
412        assert_eq!(address_domain("alice@Example.COM").unwrap(), "example.com");
413        // A username may itself contain '@' in principle, so the split takes
414        // the last one.
415        assert_eq!(address_domain("a@b@example.com").unwrap(), "example.com");
416        assert!(address_domain("not-an-address").is_err());
417    }
418
419    /// Compared against the same normalization the signed message uses, so a
420    /// scheme, a mount path or a trailing slash on the configured value is not
421    /// a mismatch.
422    #[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    /// Both domains are named, since the caller has to see which one this SDK
440    /// talks to before it can tell which side is stale.
441    #[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}