From 8db2c14aec52c4e4e84e0bd3a69f22482c15d83c Mon Sep 17 00:00:00 2001 From: Andrei <92177534+andrei-21@users.noreply.github.com> Date: Thu, 16 May 2024 17:23:11 +0100 Subject: [PATCH] Parse phone number (#1073) --- .cargo/config.toml.breez.sample | 5 ++ Cargo.lock | 81 ++++++++++++++++++++-- Cargo.toml | 1 + examples/node/cli.rs | 37 +++++++--- examples/node/overview.rs | 1 + src/environment.rs | 15 ++++ src/errors.rs | 14 ++++ src/lib.rs | 53 ++++++++++++--- src/lipalightninglib.udl | 15 +++- src/payment.rs | 31 ++++++--- src/phone_number.rs | 117 ++++++++++++++++++++++++++++++++ 11 files changed, 338 insertions(+), 32 deletions(-) create mode 100644 src/phone_number.rs diff --git a/.cargo/config.toml.breez.sample b/.cargo/config.toml.breez.sample index e1ba53126..101874c2f 100644 --- a/.cargo/config.toml.breez.sample +++ b/.cargo/config.toml.breez.sample @@ -21,6 +21,11 @@ POCKET_URL_DEV = "" POCKET_URL_STAGE = "" POCKET_URL_PROD = "" +LIPA_LIGHTNING_DOMAIN_LOCAL = "" +LIPA_LIGHTNING_DOMAIN_DEV = "" +LIPA_LIGHTNING_DOMAIN_STAGE = "" +LIPA_LIGHTNING_DOMAIN_PROD = "" + BREEZ_SDK_API_KEY = "" BREEZ_SDK_PARTNER_CERTIFICATE = "" BREEZ_SDK_PARTNER_KEY = "" diff --git a/Cargo.lock b/Cargo.lock index 45ade468c..8d5b0b5ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1104,9 +1104,9 @@ dependencies = [ [[package]] name = "either" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" [[package]] name = "electrum-client" @@ -2103,6 +2103,12 @@ dependencies = [ "secp256k1 0.24.3", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -2137,6 +2143,7 @@ dependencies = [ "parrot", "parser", "perro", + "phonenumber", "pigeon", "qrcode", "rand", @@ -2176,6 +2183,15 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "matchit" version = "0.7.3" @@ -2429,6 +2445,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + [[package]] name = "oneshot-uniffi" version = "0.1.6" @@ -2615,6 +2637,27 @@ dependencies = [ "indexmap 2.2.6", ] +[[package]] +name = "phonenumber" +version = "0.3.5+8.13.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f174c8db59b620032bd52b655fc97000458850fec0db35fcd4e802b668517ec0" +dependencies = [ + "bincode", + "either", + "fnv", + "itertools", + "lazy_static", + "nom", + "quick-xml", + "regex", + "regex-cache", + "serde", + "serde_derive", + "strum", + "thiserror", +] + [[package]] name = "pigeon" version = "0.1.0" @@ -2827,6 +2870,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9318ead08c799aad12a55a3e78b82e0b6167271ffd1f627b758891282f739187" +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.35" @@ -2916,7 +2968,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax", + "regex-syntax 0.8.3", ] [[package]] @@ -2927,9 +2979,27 @@ checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.3", ] +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.3" @@ -3617,6 +3687,9 @@ name = "strum" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] [[package]] name = "strum_macros" diff --git a/Cargo.toml b/Cargo.toml index cd927304e..5712db31d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ hex = "0.4.3" iban_validate = "4.0.1" log = "0.4.21" num_enum = "0.7.2" +phonenumber = "0.3.5" rand = "0.8.5" regex = { version = "1.10.4" } reqwest = { version = "0.11", default-features = false, features = ["json", "blocking", "rustls-tls"] } diff --git a/examples/node/cli.rs b/examples/node/cli.rs index a4ea54946..c47f57546 100644 --- a/examples/node/cli.rs +++ b/examples/node/cli.rs @@ -22,7 +22,7 @@ use uniffi_lipalightninglib::{ ExchangeRate, FailedSwapInfo, FiatValue, IncomingPaymentInfo, InvoiceCreationMetadata, InvoiceDetails, LightningNode, LiquidityLimit, LnUrlPayDetails, LnUrlWithdrawDetails, MaxRoutingFeeMode, OfferInfo, OfferKind, OutgoingPaymentInfo, PaymentInfo, PaymentMetadata, - TzConfig, + Recipient, TzConfig, }; pub(crate) fn poll_for_user_input(node: &LightningNode, log_file_path: &str) { @@ -104,6 +104,15 @@ pub(crate) fn poll_for_user_input(node: &LightningNode, log_file_path: &str) { println!("{}", format!("{message:#}").red()); } } + "parsephonenumber" => { + let number = words.collect::>().join(" "); + let allowed_countries = + vec!["AT".to_string(), "CH".to_string(), "DE".to_string()]; + match node.parse_phone_number(number, allowed_countries) { + Ok(address) => println!("{address}"), + Err(message) => println!("{}", format!("{message:#}").red()), + } + } "getmaxroutingfeemode" => { if let Err(message) = get_max_routing_fee_mode(node, &mut words) { println!("{}", format!("{message:#}").red()); @@ -194,8 +203,18 @@ pub(crate) fn poll_for_user_input(node: &LightningNode, log_file_path: &str) { println!("{}", format!("{message:#}").red()); } } - "listlightningaddresses" => match node.list_lightning_addresses() { - Ok(list) => println!("{}", list.join("\n")), + "listrecipients" => match node.list_recipients() { + Ok(list) => { + let list = list + .into_iter() + .map(|r| match r { + Recipient::LightningAddress { address } => address, + Recipient::PhoneNumber { e164 } => e164, + r => panic!("{r:?}"), + }) + .collect::>(); + println!("{}", list.join("\n")); + } Err(message) => eprintln!("{}", format!("{message:#}").red()), }, "paymentuuid" => match payment_uuid(node, &mut words) { @@ -322,6 +341,10 @@ fn setup_editor(history_path: &Path) -> Editor { )); hints.insert(CommandHint::new("d ", "d ")); hints.insert(CommandHint::new("decodedata ", "decodedata ")); + hints.insert(CommandHint::new( + "parsephonenumber ", + "parsephonenumber ", + )); hints.insert(CommandHint::new( "getmaxroutingfeemode ", "getmaxroutingfeemode ", @@ -382,10 +405,7 @@ fn setup_editor(history_path: &Path) -> Editor { "listactivities [number of activities = 2]", "listactivities ", )); - hints.insert(CommandHint::new( - "listlightningaddresses", - "listlightningaddresses", - )); + hints.insert(CommandHint::new("listrecipients", "listrecipients")); hints.insert(CommandHint::new( "registerlightningaddress", "registerlightningaddress", @@ -442,6 +462,7 @@ fn help() { println!(); println!(" i | invoice [description]"); println!(" d | decodedata "); + println!(" parsephonenumber "); println!(" getmaxroutingfeemode "); println!(" getinvoiceaffordability "); println!(" p | payinvoice "); @@ -463,7 +484,7 @@ fn help() { println!(); println!(" o | overview [number of activities = 10] [fun mode = false]"); println!(" l | listactivities [number of activities = 2]"); - println!(" listlightningaddresses"); + println!(" listrecipients"); println!(" registerlightningaddress"); println!(" querylightningaddress"); println!(" paymentuuid "); diff --git a/examples/node/overview.rs b/examples/node/overview.rs index 068c2dec9..4cc485ebb 100644 --- a/examples/node/overview.rs +++ b/examples/node/overview.rs @@ -198,6 +198,7 @@ fn print_outgoing_payment(payment: OutgoingPaymentInfo) -> Result<()> { let (icon, title) = match payment.recipient { Recipient::LightningAddress { address } => (" @".bold(), address), Recipient::LnUrlPayDomain { domain } => ("๐ŸŒ".normal(), domain), + Recipient::PhoneNumber { e164 } => ("๐Ÿ“ž".normal(), e164), Recipient::Unknown => ("๐Ÿงพ".normal(), "Invoice".to_string()), }; diff --git a/src/environment.rs b/src/environment.rs index 62d35847c..0a8b97f92 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -22,6 +22,7 @@ pub(crate) struct Environment { pub pocket_url: String, pub notification_webhook_base_url: String, pub notification_webhook_secret: [u8; 32], + pub lipa_lightning_domain: String, } impl Environment { @@ -33,6 +34,7 @@ impl Environment { let notification_webhook_base_url = get_notification_webhook_base_url(environment).to_string(); let notification_webhook_secret = get_notification_webhook_secret(environment)?; + let lipa_lightning_domain = get_lipa_lightning_domain(environment).to_string(); Ok(match environment { EnvironmentCode::Local => Self { @@ -43,6 +45,7 @@ impl Environment { pocket_url: env!("POCKET_URL_LOCAL").to_string(), notification_webhook_base_url, notification_webhook_secret, + lipa_lightning_domain, }, EnvironmentCode::Dev => Self { network: Network::Bitcoin, @@ -52,6 +55,7 @@ impl Environment { pocket_url: env!("POCKET_URL_DEV").to_string(), notification_webhook_base_url, notification_webhook_secret, + lipa_lightning_domain, }, EnvironmentCode::Stage => Self { network: Network::Bitcoin, @@ -61,6 +65,7 @@ impl Environment { pocket_url: env!("POCKET_URL_STAGE").to_string(), notification_webhook_base_url, notification_webhook_secret, + lipa_lightning_domain, }, EnvironmentCode::Prod => Self { network: Network::Bitcoin, @@ -70,6 +75,7 @@ impl Environment { pocket_url: env!("POCKET_URL_PROD").to_string(), notification_webhook_base_url, notification_webhook_secret, + lipa_lightning_domain, }, }) } @@ -104,3 +110,12 @@ fn get_notification_webhook_secret(environment_code: EnvironmentCode) -> Result< <[u8; 32]>::from_hex(secret_hex) .map_to_permanent_failure("Failed to parse embedded notification webhook secret") } + +fn get_lipa_lightning_domain(environment_code: EnvironmentCode) -> &'static str { + match environment_code { + EnvironmentCode::Local => env!("LIPA_LIGHTNING_DOMAIN_LOCAL"), + EnvironmentCode::Dev => env!("LIPA_LIGHTNING_DOMAIN_DEV"), + EnvironmentCode::Stage => env!("LIPA_LIGHTNING_DOMAIN_STAGE"), + EnvironmentCode::Prod => env!("LIPA_LIGHTNING_DOMAIN_PROD"), + } +} diff --git a/src/errors.rs b/src/errors.rs index eb08eaacd..eb1a71327 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -329,3 +329,17 @@ impl NotificationHandlingErrorCode { Self::NodeUnavailable } } + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum ParsePhoneNumberError { + #[error("ParsingError")] + ParsingError, + #[error("MissingCountryCode")] + MissingCountryCode, + #[error("InvalidCountryCode")] + InvalidCountryCode, + #[error("InvalidPhoneNumber")] + InvalidPhoneNumber, + #[error("UnsupportedCountry")] + UnsupportedCountry, +} diff --git a/src/lib.rs b/src/lib.rs index f4a809905..0696eae24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,7 @@ mod migrations; mod notification_handling; mod offer; mod payment; +mod phone_number; mod random; mod recovery; mod sanitize_input; @@ -62,8 +63,8 @@ use crate::errors::{ }; pub use crate::errors::{ DecodeDataError, Error as LnError, LnUrlPayError, LnUrlPayErrorCode, LnUrlPayResult, - MnemonicError, NotificationHandlingError, NotificationHandlingErrorCode, PayError, - PayErrorCode, PayResult, Result, RuntimeErrorCode, SimpleError, UnsupportedDataType, + MnemonicError, NotificationHandlingError, NotificationHandlingErrorCode, ParsePhoneNumberError, + PayError, PayErrorCode, PayResult, Result, RuntimeErrorCode, SimpleError, UnsupportedDataType, }; use crate::event::LipaEventListener; pub use crate::exchange_rate_provider::ExchangeRate; @@ -80,6 +81,7 @@ pub use crate::offer::{OfferInfo, OfferKind, OfferStatus}; pub use crate::payment::{ IncomingPaymentInfo, OutgoingPaymentInfo, PaymentInfo, PaymentState, Recipient, }; +pub use crate::phone_number::PhoneNumber; pub use crate::recovery::recover_lightning_node; pub use crate::secret::{generate_secret, mnemonic_to_secret, words_by_prefix, Secret}; pub use crate::swap::{ @@ -613,6 +615,26 @@ impl LightningNode { )) } + /// Parse a phone number, check against the list of allowed country. + /// + /// Returns a possible lightning address, which can be checked for existence + /// with [`LightningNode::decode_data`]. + /// + /// Requires network: **no** + pub fn parse_phone_number( + &self, + phone_number: String, + allowed_countries_country_iso_3166_1_alpha_2: Vec, + ) -> std::result::Result { + let phone_number = PhoneNumber::parse(&phone_number)?; + ensure!( + allowed_countries_country_iso_3166_1_alpha_2 + .contains(&phone_number.country_code.as_ref().to_string()), + ParsePhoneNumberError::UnsupportedCountry + ); + Ok(phone_number.to_lightning_address(&self.environment.lipa_lightning_domain)) + } + /// Decode a user-provided string (usually obtained from QR-code or pasted). /// /// Requires network: **yes** @@ -845,12 +867,12 @@ impl LightningNode { Ok(payment_hash) } - /// List lightning addresses from the most recent used. + /// List recipients from the most recent used. /// - /// Returns a list of lightning addresses. + /// Returns a list of recipients (lightning addresses or phone numbers for now). /// /// Requires network: **no** - pub fn list_lightning_addresses(&self) -> Result> { + pub fn list_recipients(&self) -> Result> { let list_payments_request = ListPaymentsRequest { filters: Some(vec![PaymentTypeFilter::Sent]), metadata_filters: None, @@ -878,7 +900,14 @@ impl LightningNode { lightning_addresses.sort(); lightning_addresses.dedup_by_key(|p| p.0.clone()); lightning_addresses.sort_by_key(|p| p.1); - Ok(lightning_addresses.into_iter().map(|p| p.0).collect()) + + let recipients = lightning_addresses + .into_iter() + .map(|p| { + Recipient::from_lightning_address(&p.0, &self.environment.lipa_lightning_domain) + }) + .collect(); + Ok(recipients) } /// Withdraw an LNURL-withdraw the provided amount. @@ -1194,6 +1223,7 @@ impl LightningNode { personal_note, received_on, received_lnurl_comment, + &self.environment.lipa_lightning_domain, )?; let offer_kind = fill_payout_fee( offer, @@ -1219,6 +1249,7 @@ impl LightningNode { personal_note, received_on, received_lnurl_comment, + &self.environment.lipa_lightning_domain, )?; Ok(Activity::Swap { incoming_payment_info: Some(incoming_payment_info), @@ -1232,13 +1263,19 @@ impl LightningNode { personal_note, received_on, received_lnurl_comment, + &self.environment.lipa_lightning_domain, )?; Ok(Activity::IncomingPayment { incoming_payment_info, }) } else if breez_payment.payment_type == breez_sdk_core::PaymentType::Sent { - let outgoing_payment_info = - OutgoingPaymentInfo::new(breez_payment, &exchange_rate, tz_config, personal_note)?; + let outgoing_payment_info = OutgoingPaymentInfo::new( + breez_payment, + &exchange_rate, + tz_config, + personal_note, + &self.environment.lipa_lightning_domain, + )?; Ok(Activity::OutgoingPayment { outgoing_payment_info, }) diff --git a/src/lipalightninglib.udl b/src/lipalightninglib.udl index 7e2221a2d..eb388ae7a 100644 --- a/src/lipalightninglib.udl +++ b/src/lipalightninglib.udl @@ -24,6 +24,9 @@ interface LightningNode { [Throws=DecodeDataError] DecodedData decode_data(string data); + [Throws=ParsePhoneNumberError] + string parse_phone_number(string phone_number, sequence allowed_countries_country_iso_3166_1_alpha_2); + MaxRoutingFeeMode get_payment_max_routing_fee_mode(u64 amount_sat); [Throws=LnError] @@ -51,7 +54,7 @@ interface LightningNode { void set_payment_personal_note(string payment_hash, string note); [Throws=LnError] - sequence list_lightning_addresses(); + sequence list_recipients(); [Throws=LnUrlWithdrawError] string withdraw_lnurlw(LnUrlWithdrawRequestData lnurl_withdraw_request_data, u64 amount_sat); @@ -591,6 +594,7 @@ dictionary SwapToLightningFees { interface Recipient { LightningAddress(string address); LnUrlPayDomain(string domain); + PhoneNumber(string e164); Unknown(); }; @@ -719,6 +723,15 @@ interface DecodeDataError { Unrecognized(string msg); }; +[Error] +interface ParsePhoneNumberError { + ParsingError(); + MissingCountryCode(); + InvalidCountryCode(); + InvalidPhoneNumber(); + UnsupportedCountry(); +}; + [Error] interface LnUrlPayError { InvalidInput(string msg); diff --git a/src/payment.rs b/src/payment.rs index 872817f46..5b8fc9105 100644 --- a/src/payment.rs +++ b/src/payment.rs @@ -3,6 +3,7 @@ use std::ops::Add; use crate::amount::{AsSats, ToAmount}; use crate::config::WithTimezone; use crate::lnurl::parse_metadata; +use crate::phone_number::lightning_address_to_phone_number; use crate::util::unix_timestamp_to_system_time; use crate::{Amount, ExchangeRate, InvoiceDetails, Result, TzConfig, TzTime}; @@ -162,6 +163,7 @@ impl IncomingPaymentInfo { personal_note: Option, received_on: Option, received_lnurl_comment: Option, + lipa_lightning_domain: &str, ) -> Result { let lsp_fees = breez_payment .fee_msat @@ -174,7 +176,8 @@ impl IncomingPaymentInfo { .to_amount_down(exchange_rate); let payment_info = PaymentInfo::new(breez_payment, exchange_rate, tz_config, personal_note)?; - let received_on = received_on.map(|r| Recipient::from_str(&r)); + let received_on = + received_on.map(|r| Recipient::from_lightning_address(&r, lipa_lightning_domain)); Ok(Self { payment_info, requested_amount, @@ -204,6 +207,7 @@ impl OutgoingPaymentInfo { exchange_rate: &Option, tz_config: TzConfig, personal_note: Option, + lipa_lightning_domain: &str, ) -> Result { let network_fees = breez_payment .fee_msat @@ -215,7 +219,7 @@ impl OutgoingPaymentInfo { permanent_failure!("OutgoingPaymentInfo cannot be created from channel close") } }; - let recipient = Recipient::from_ln_payment_details(data); + let recipient = Recipient::from_ln_payment_details(data, lipa_lightning_domain); let comment_for_recipient = data.lnurl_pay_comment.clone(); let payment_info = PaymentInfo::new(breez_payment, exchange_rate, tz_config, personal_note)?; @@ -233,12 +237,19 @@ impl OutgoingPaymentInfo { pub enum Recipient { LightningAddress { address: String }, LnUrlPayDomain { domain: String }, + PhoneNumber { e164: String }, Unknown, } impl Recipient { - pub(crate) fn from_ln_payment_details(payment_details: &LnPaymentDetails) -> Self { + pub(crate) fn from_ln_payment_details( + payment_details: &LnPaymentDetails, + lipa_lightning_domain: &str, + ) -> Self { if let Some(address) = &payment_details.ln_address { + if let Some(e164) = lightning_address_to_phone_number(address, lipa_lightning_domain) { + return Recipient::PhoneNumber { e164 }; + } Recipient::LightningAddress { address: address.to_string(), } @@ -251,14 +262,12 @@ impl Recipient { } } - pub(crate) fn from_str(str: &str) -> Self { - if parser::parse_lightning_address(str).is_ok() { - // TODO: check if lightning address matches phone number format - Recipient::LightningAddress { - address: str.to_string(), - } - } else { - Recipient::Unknown + pub(crate) fn from_lightning_address(address: &str, lipa_lightning_domain: &str) -> Self { + match lightning_address_to_phone_number(address, lipa_lightning_domain) { + Some(e164) => Recipient::PhoneNumber { e164 }, + None => Recipient::LightningAddress { + address: address.to_string(), + }, } } } diff --git a/src/phone_number.rs b/src/phone_number.rs new file mode 100644 index 000000000..1ccfe60ac --- /dev/null +++ b/src/phone_number.rs @@ -0,0 +1,117 @@ +use crate::errors::ParsePhoneNumberError; +use perro::ensure; +use phonenumber::country::Id as CountryCode; +use phonenumber::ParseError; + +#[derive(PartialEq, Debug)] +pub struct PhoneNumber { + pub e164: String, + pub country_code: CountryCode, +} + +impl PhoneNumber { + pub(crate) fn parse(number: &str) -> Result { + let number = match phonenumber::parse(None, number) { + Ok(number) => number, + Err(ParseError::InvalidCountryCode) => { + return Err(ParsePhoneNumberError::MissingCountryCode) + } + Err(_) => return Err(ParsePhoneNumberError::ParsingError), + }; + ensure!(number.is_valid(), ParsePhoneNumberError::InvalidPhoneNumber); + + let e164 = number.format().mode(phonenumber::Mode::E164).to_string(); + let country_code = number + .country() + .id() + .ok_or(ParsePhoneNumberError::InvalidCountryCode)?; + Ok(PhoneNumber { e164, country_code }) + } + + pub(crate) fn to_lightning_address(&self, domain: &str) -> String { + self.e164.replacen('+', "-", 1) + domain + } +} + +pub(crate) fn lightning_address_to_phone_number(address: &str, domain: &str) -> Option { + let username = address + .strip_prefix('-') + .and_then(|s| s.strip_suffix(domain)); + if let Some(username) = username { + if username.chars().all(|c| char::is_ascii_digit(&c)) { + return Some(format!("+{username}")); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + static LIPA_DOMAIN: &str = "@lipa.swiss"; + + #[test] + fn test_parse_phone_number() { + let expected = PhoneNumber { + e164: "+41446681800".to_string(), + country_code: CountryCode::CH, + }; + assert_eq!(PhoneNumber::parse("+41 44 668 18 00").unwrap(), expected); + assert_eq!( + PhoneNumber::parse("tel:+41-44-668-18-00").unwrap(), + expected, + ); + assert_eq!(PhoneNumber::parse("+41446681800").unwrap(), expected); + + assert_eq!( + PhoneNumber::parse("044 668 18 00").unwrap_err(), + ParsePhoneNumberError::MissingCountryCode + ); + assert_eq!( + PhoneNumber::parse("446681800").unwrap_err(), + ParsePhoneNumberError::MissingCountryCode + ); + // Missing the last digit. + assert_eq!( + PhoneNumber::parse("+41 44 668 18 0").unwrap_err(), + ParsePhoneNumberError::InvalidPhoneNumber + ); + } + + #[test] + fn test_to_from_lightning_address_e2e() { + let original = PhoneNumber::parse("+41 44 668 18 00").unwrap(); + let address = original.to_lightning_address(LIPA_DOMAIN); + let e164 = lightning_address_to_phone_number(&address, LIPA_DOMAIN).unwrap(); + let result = PhoneNumber::parse(&e164).unwrap(); + assert_eq!(original.e164, result.e164); + } + + #[test] + fn test_to_from_lightning_address() { + assert_eq!( + PhoneNumber::parse("+41 44 668 18 00") + .unwrap() + .to_lightning_address(LIPA_DOMAIN), + "-41446681800@lipa.swiss", + ); + + assert_eq!( + lightning_address_to_phone_number("-41446681800@lipa.swiss", LIPA_DOMAIN).unwrap(), + "+41446681800" + ); + assert_eq!( + lightning_address_to_phone_number("41446681800@lipa.swiss", LIPA_DOMAIN), + None + ); + assert_eq!( + lightning_address_to_phone_number("-41446681800@other.domain", LIPA_DOMAIN), + None + ); + assert_eq!( + lightning_address_to_phone_number("-4144668aa1800@lipa.swiss", LIPA_DOMAIN), + None + ); + } +}