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};
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
15/// Lowercase compressed hex, the form the server rebuilds the signed message
16/// with. A caller-supplied pubkey that differs only in case or encoding would
17/// otherwise produce a message the server never reconstructs, and the only
18/// symptom would be "invalid signature".
19fn 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
25/// The domain a `{username}@{domain}` lightning address lives on.
26///
27/// The address is the server's own record of the domain it resolved when the
28/// registration was made, so it names where the registration lives even after
29/// this SDK is pointed elsewhere.
30fn 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
41/// Rejects a domain that is not the one this SDK talks to, naming both so the
42/// failure says which server that is.
43///
44/// This belongs to the side that makes the request: the domain its server
45/// resolves is what a signature has to match. The side producing a signature for
46/// someone else to submit has no such constraint.
47fn 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
61/// Names the address as a payer would type it, so the configured domain is
62/// reduced to the authority the server registers the address under rather than
63/// interpolated whole.
64fn 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    /// Check whether a username is free for this wallet to register.
75    ///
76    /// The check is signed with the wallet's identity key, so every call costs
77    /// a signing operation and a server round trip: run it when the user
78    /// finishes typing, not on every keystroke.
79    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(&params).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    /// Authorize transferring the current owner's registered lightning address
138    /// username to `request.transferee_pubkey`. Returns a
139    /// [`TransferAuthorization`] to hand to the new owner, who
140    /// claims it via [`BreezSdk::claim_lightning_address_transfer`].
141    /// Errors if the current owner has no lightning address registered.
142    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        // The domain the address is registered on, not this SDK's configured
154        // one. The transferee submits the transfer, so what the signature has to
155        // cover is the domain their server resolves, and this SDK's own
156        // configuration is not part of that: an address cached from a domain it
157        // no longer points at is still a registration it can hand over.
158        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    /// Claim a lightning address username handed over by its current owner,
184    /// using the [`TransferAuthorization`] from
185    /// [`BreezSdk::authorize_lightning_address_transfer`]. Completes the
186    /// takeover and returns the newly-owned address.
187    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        // Checked before the round trip so the failure names both domains, and
199        // says which one this SDK is configured for.
200        require_configured_domain(
201            client.domain(),
202            &request.authorization.domain,
203            "the authorization",
204        )?;
205        // The window the server enforces, applied here so an authorization the
206        // transferee sat on fails saying so rather than as a generic rejection.
207        // Deliberately short: nothing revokes an authorization, so expiry is the
208        // only thing that takes one back.
209        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(&params).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    /// Give up this wallet's lightning address.
244    ///
245    /// The server holds the address for this wallet afterwards: while the hold
246    /// stands only this wallet can register it, so payers who kept the old
247    /// address are not redirected to someone else. How long a hold stands is
248    /// the server's policy.
249    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(&params).await {
266            Ok(()) => {}
267            // A 409 is either a name this wallet no longer holds (another
268            // device re-registered under the same identity key) or a statement
269            // the server already acted on. Resync settles both: it re-caches an
270            // address that is still there and clears one that is gone, so a
271            // retry either signs the real address or short-circuits.
272            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
288// Private lightning address methods
289impl BreezSdk {
290    /// Attempts to recover a lightning address from the lnurl server.
291    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        // Key absent -> None (never recovered)
353        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        // Save an address, then delete it
363        cache
364            .save_lightning_address(&sample_address_info(), false)
365            .await
366            .unwrap();
367        cache.delete_lightning_address(false).await.unwrap();
368
369        // Key present, value null -> Some(None) (recovered, no address)
370        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        // Key present, value non-null -> Some(Some(info))
388        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    /// Read from the address itself, so it names the domain the address is
401    /// actually registered on rather than whatever the SDK is configured with.
402    #[test]
403    fn the_domain_comes_from_the_cached_address() {
404        assert_eq!(address_domain("alice@example.com").unwrap(), "example.com");
405        // Lowercased to match what the server resolves and lowercases.
406        assert_eq!(address_domain("alice@Example.COM").unwrap(), "example.com");
407        // A username may itself contain '@' in principle, so the split takes
408        // the last one.
409        assert_eq!(address_domain("a@b@example.com").unwrap(), "example.com");
410        assert!(address_domain("not-an-address").is_err());
411    }
412
413    /// Compared against the same normalization the signed message uses, so a
414    /// scheme, a mount path or a trailing slash on the configured value is not
415    /// a mismatch.
416    #[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    /// Both domains are named, since the caller has to see which one this SDK
434    /// talks to before it can tell which side is stale.
435    #[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}