From 1510a1b28dcab5d97b3c1d05b50d6a7b716d9670 Mon Sep 17 00:00:00 2001 From: Ross Savage Date: Thu, 9 Nov 2023 14:51:31 +0100 Subject: [PATCH] Validate the invoice network when sending payments --- libs/sdk-bindings/src/breez_sdk.udl | 3 + libs/sdk-bindings/src/uniffi_binding.rs | 2 +- libs/sdk-core/src/binding.rs | 2 +- libs/sdk-core/src/breez_services.rs | 1096 ++++++++++------- libs/sdk-core/src/bridge_generated.rs | 1 + libs/sdk-core/src/error.rs | 43 +- libs/sdk-core/src/greenlight/node_api.rs | 14 +- libs/sdk-core/src/input_parser.rs | 4 +- libs/sdk-core/src/invoice.rs | 66 +- libs/sdk-core/src/lnurl/pay.rs | 66 +- libs/sdk-core/src/lnurl/withdraw.rs | 6 +- libs/sdk-core/src/models.rs | 2 +- libs/sdk-core/src/persist/transactions.rs | 2 +- libs/sdk-core/src/test_utils.rs | 4 +- libs/sdk-flutter/lib/bridge_generated.dart | 25 +- .../main/java/com/breezsdk/BreezSDKMapper.kt | 4 + .../sdk-react-native/ios/BreezSDKMapper.swift | 5 + libs/sdk-react-native/src/index.ts | 1 + 18 files changed, 832 insertions(+), 514 deletions(-) diff --git a/libs/sdk-bindings/src/breez_sdk.udl b/libs/sdk-bindings/src/breez_sdk.udl index b188a841a..d2c3eb536 100644 --- a/libs/sdk-bindings/src/breez_sdk.udl +++ b/libs/sdk-bindings/src/breez_sdk.udl @@ -28,6 +28,7 @@ enum LnUrlPayError { "Generic", "InvalidAmount", "InvalidInvoice", + "InvalidNetwork", "InvalidUri", "InvoiceExpired", "PaymentFailed", @@ -81,6 +82,7 @@ enum SendPaymentError { "InvalidAmount", "InvalidInvoice", "InvoiceExpired", + "InvalidNetwork", "PaymentFailed", "PaymentTimeout", "RouteNotFound", @@ -134,6 +136,7 @@ dictionary RouteHint { dictionary LNInvoice { string bolt11; + Network network; string payee_pubkey; string payment_hash; string? description; diff --git a/libs/sdk-bindings/src/uniffi_binding.rs b/libs/sdk-bindings/src/uniffi_binding.rs index c601190f3..39c5e388c 100644 --- a/libs/sdk-bindings/src/uniffi_binding.rs +++ b/libs/sdk-bindings/src/uniffi_binding.rs @@ -307,7 +307,7 @@ impl BlockingBreezServices { } pub fn parse_invoice(invoice: String) -> SdkResult { - Ok(sdk_parse_invoice(&invoice)?) + Ok(sdk_parse_invoice(&invoice, None)?) } pub fn parse_input(s: String) -> SdkResult { diff --git a/libs/sdk-core/src/binding.rs b/libs/sdk-core/src/binding.rs index 4b5c9d665..d4ef1605d 100644 --- a/libs/sdk-core/src/binding.rs +++ b/libs/sdk-core/src/binding.rs @@ -228,7 +228,7 @@ pub fn backup_status() -> Result { /* Parse API's */ pub fn parse_invoice(invoice: String) -> Result { - invoice::parse_invoice(&invoice).map_err(|e| anyhow::Error::new::(e.into())) + invoice::parse_invoice(&invoice, None).map_err(|e| anyhow::Error::new::(e.into())) } pub fn parse_input(input: String) -> Result { diff --git a/libs/sdk-core/src/breez_services.rs b/libs/sdk-core/src/breez_services.rs index 6d622cc8a..f3bb1d498 100644 --- a/libs/sdk-core/src/breez_services.rs +++ b/libs/sdk-core/src/breez_services.rs @@ -156,10 +156,13 @@ pub struct BreezServices { moonpay_api: Arc, chain_service: Arc, persister: Arc, + notifier: Arc, + syncer: Arc, payment_receiver: Arc, + payment_sender: Arc, + lnurl_handler: Arc, btc_receive_swapper: Arc, btc_send_swapper: Arc, - event_listener: Option>, backup_watcher: Arc, shutdown_sender: watch::Sender<()>, shutdown_receiver: watch::Receiver<()>, @@ -238,47 +241,7 @@ impl BreezServices { &self, req: SendPaymentRequest, ) -> Result { - self.start_node().await?; - let parsed_invoice = parse_invoice(req.bolt11.as_str())?; - let invoice_amount_msat = parsed_invoice.amount_msat.unwrap_or_default(); - let provided_amount_msat = req.amount_msat.unwrap_or_default(); - - // Ensure amount is provided for zero invoice - if provided_amount_msat == 0 && invoice_amount_msat == 0 { - return Err(SendPaymentError::InvalidAmount { - err: "Amount must be provided when paying a zero invoice".into(), - }); - } - - // Ensure amount is not provided for invoice that contains amount - if provided_amount_msat > 0 && invoice_amount_msat > 0 { - return Err(SendPaymentError::InvalidAmount { - err: "Amount should not be provided when paying a non zero invoice".into(), - }); - } - - match self - .persister - .get_completed_payment_by_hash(&parsed_invoice.payment_hash)? - { - Some(_) => Err(SendPaymentError::AlreadyPaid), - None => { - let payment_res = self - .node_api - .send_payment(req.bolt11.clone(), req.amount_msat) - .map_err(Into::into) - .await; - let payment = self - .on_payment_completed( - parsed_invoice.payee_pubkey.clone(), - Some(parsed_invoice), - req.amount_msat, - payment_res, - ) - .await?; - Ok(SendPaymentResponse { payment }) - } - } + self.payment_sender.send_payment(req).await } /// Pay directly to a node id using keysend @@ -286,16 +249,7 @@ impl BreezServices { &self, req: SendSpontaneousPaymentRequest, ) -> Result { - self.start_node().await?; - let payment_res = self - .node_api - .send_spontaneous_payment(req.node_id.clone(), req.amount_msat) - .map_err(Into::into) - .await; - let payment = self - .on_payment_completed(req.node_id, None, Some(req.amount_msat), payment_res) - .await?; - Ok(SendPaymentResponse { payment }) + self.payment_sender.send_spontaneous_payment(req).await } /// Second step of LNURL-pay. The first step is `parse()`, which also validates the LNURL destination @@ -307,86 +261,7 @@ impl BreezServices { /// /// This method will return an [anyhow::Error] when any validation check fails. pub async fn lnurl_pay(&self, req: LnUrlPayRequest) -> Result { - match validate_lnurl_pay(req.amount_msat, req.comment, req.data.clone()).await? { - ValidatedCallbackResponse::EndpointError { data: e } => { - Ok(LnUrlPayResult::EndpointError { data: e }) - } - ValidatedCallbackResponse::EndpointSuccess { data: cb } => { - let pay_req = SendPaymentRequest { - bolt11: cb.pr.clone(), - amount_msat: None, - }; - - let payment = match self.send_payment(pay_req).await { - Ok(p) => Ok(p), - Err(e) => match e { - SendPaymentError::InvalidInvoice { .. } => Err(e), - SendPaymentError::ServiceConnectivity { .. } => Err(e), - _ => { - let invoice = parse_invoice(cb.pr.as_str())?; - - return Ok(LnUrlPayResult::PayError { - data: LnUrlPayErrorData { - payment_hash: invoice.payment_hash, - reason: e.to_string(), - }, - }); - } - }, - }? - .payment; - let details = match &payment.details { - PaymentDetails::ClosedChannel { .. } => { - return Err(LnUrlPayError::Generic { - err: "Payment lookup found unexpected payment type".into(), - }); - } - PaymentDetails::Ln { data } => data, - }; - - let maybe_sa_processed: Option = match cb.success_action { - Some(sa) => { - let processed_sa = match sa { - // For AES, we decrypt the contents on the fly - Aes(data) => { - let preimage = sha256::Hash::from_str(&details.payment_preimage)?; - let preimage_arr: [u8; 32] = preimage.into_inner(); - - let decrypted = (data, &preimage_arr).try_into().map_err( - |e: anyhow::Error| LnUrlPayError::AesDecryptionFailed { - err: e.to_string(), - }, - )?; - SuccessActionProcessed::Aes { data: decrypted } - } - SuccessAction::Message(data) => { - SuccessActionProcessed::Message { data } - } - SuccessAction::Url(data) => SuccessActionProcessed::Url { data }, - }; - Some(processed_sa) - } - None => None, - }; - - // Store SA (if available) + LN Address in separate table, associated to payment_hash - self.persister.insert_payment_external_info( - &details.payment_hash, - maybe_sa_processed.as_ref(), - Some(req.data.metadata_str), - req.data.ln_address, - None, - None, - )?; - - Ok(LnUrlPayResult::EndpointSuccess { - data: LnUrlPaySuccessData { - payment_hash: details.payment_hash.clone(), - success_action: maybe_sa_processed, - }, - }) - } - } + self.lnurl_handler.lnurl_pay(req).await } /// Second step of LNURL-withdraw. The first step is `parse()`, which also validates the LNURL destination @@ -399,32 +274,7 @@ impl BreezServices { &self, req: LnUrlWithdrawRequest, ) -> Result { - let invoice = self - .receive_payment(ReceivePaymentRequest { - amount_msat: req.amount_msat, - description: req.description.unwrap_or_default(), - use_description_hash: Some(false), - ..Default::default() - }) - .await? - .ln_invoice; - - let lnurl_w_endpoint = req.data.callback.clone(); - let res = validate_lnurl_withdraw(req.data, invoice).await?; - - if let LnUrlWithdrawResult::Ok { ref data } = res { - // If endpoint was successfully called, store the LNURL-withdraw endpoint URL as metadata linked to the invoice - self.persister.insert_payment_external_info( - &data.invoice.payment_hash, - None, - None, - None, - Some(lnurl_w_endpoint), - None, - )?; - } - - Ok(res) + self.lnurl_handler.lnurl_withdraw(req).await } /// Third and last step of LNURL-auth. The first step is `parse()`, which also validates the LNURL destination @@ -436,7 +286,7 @@ impl BreezServices { &self, req_data: LnUrlAuthRequestData, ) -> Result { - Ok(perform_lnurl_auth(self.node_api.clone(), req_data).await?) + self.lnurl_handler.lnurl_auth(req_data).await } /// Creates an bolt11 payment request. @@ -744,7 +594,7 @@ impl BreezServices { .convert_reverse_swap_info(full_rsi) .await?; - self.do_sync(true).await?; + self.syncer.do_sync(true).await?; Ok(SendOnchainResponse { reverse_swap_info: rsi, }) @@ -801,218 +651,72 @@ impl BreezServices { /// * channels - The list of channels and their status /// * payments - The incoming/outgoing payments pub async fn sync(&self) -> SdkResult<()> { - Ok(self.do_sync(false).await?) + Ok(self.syncer.do_sync(false).await?) } - async fn do_sync(&self, balance_changed: bool) -> Result<()> { - let start = Instant::now(); - let node_pubkey = self.node_api.start().await?; - self.connect_lsp_peer(node_pubkey).await?; - - // First query the changes since last sync time. - let since_timestamp = self.persister.last_payment_timestamp().unwrap_or(0); - let new_data = &self - .node_api - .pull_changed(since_timestamp, balance_changed) - .await?; - - debug!( - "pull changed time={:?} {:?}", - since_timestamp, new_data.payments - ); + async fn on_event(&self, e: BreezEvent) -> Result<()> { + debug!("breez services got event {:?}", e); + self.notifier.notify_event_listeners(e.clone()).await + } - // update node state and channels state - self.persister.set_node_state(&new_data.node_state)?; + /// Convenience method to look up LSP info based on current LSP ID + pub async fn lsp_info(&self) -> SdkResult { + Ok(get_lsp(self.persister.clone(), self.lsp_api.clone()).await?) + } - let channels_before_update = self.persister.list_channels()?; - self.persister.update_channels(&new_data.channels)?; - let channels_after_update = self.persister.list_channels()?; + pub(crate) async fn start_node(&self) -> Result<()> { + self.node_api.start().await?; + Ok(()) + } - // Fetch the static backup if needed and persist it - if channels_before_update.len() != channels_after_update.len() { - info!("fetching static backup file from node"); - let backup = self.node_api.static_backup().await?; - self.persister.set_static_backup(backup)?; - } + /// Get the recommended fees for onchain transactions + pub async fn recommended_fees(&self) -> SdkResult { + Ok(self.chain_service.recommended_fees().await?) + } - //fetch closed_channel and convert them to Payment items. - let mut closed_channel_payments: Vec = vec![]; - for closed_channel in - self.persister.list_channels()?.into_iter().filter(|c| { - c.state == ChannelState::Closed || c.state == ChannelState::PendingClose - }) - { - let closed_channel_tx = self.closed_channel_to_transaction(closed_channel).await?; - closed_channel_payments.push(closed_channel_tx); + /// Get the full default config for a specific environment type + pub fn default_config( + env_type: EnvironmentType, + api_key: String, + node_config: NodeConfig, + ) -> Config { + match env_type { + EnvironmentType::Production => Config::production(api_key, node_config), + EnvironmentType::Staging => Config::staging(api_key, node_config), } + } - // update both closed channels and lightning transaction payments - let mut payments = closed_channel_payments; - payments.extend(new_data.payments.clone()); - self.persister.insert_or_update_payments(&payments)?; + /// Get the static backup data from the peristent storage. + /// This data enables the user to recover the node in an external core ligntning node. + /// See here for instructions on how to recover using this data: https://docs.corelightning.org/docs/backup-and-recovery#backing-up-using-static-channel-backup + pub fn static_backup(req: StaticBackupRequest) -> SdkResult { + let storage = SqliteStorage::new(req.working_dir); + Ok(StaticBackupResponse { + backup: storage.get_static_backup()?, + }) + } - let duration = start.elapsed(); - info!("Sync duration: {:?}", duration); + /// Generates an url that can be used by a third part provider to buy Bitcoin with fiat currency. + /// + /// A user-selected [OpeningFeeParams] can be optionally set in the argument. If set, and the + /// operation requires a new channel, the SDK will try to use the given fee params. + pub async fn buy_bitcoin( + &self, + req: BuyBitcoinRequest, + ) -> Result { + let swap_info = self + .receive_onchain(ReceiveOnchainRequest { + opening_fee_params: req.opening_fee_params, + }) + .await?; + let url = match req.provider { + Moonpay => self.moonpay_api.buy_bitcoin_url(&swap_info).await?, + }; - self.notify_event_listeners(BreezEvent::Synced).await?; - Ok(()) - } - - /// Connects to the selected LSP peer. If none is selected, this selects the first one from [`list_lsps`] and persists the selection. - async fn connect_lsp_peer(&self, node_pubkey: String) -> SdkResult<()> { - // Sets the LSP id, if not already set - if self.persister.get_lsp_id()?.is_none() { - if let Some(lsp) = self.lsp_api.list_lsps(node_pubkey).await?.first().cloned() { - self.persister.set_lsp_id(lsp.id)?; - } - } - if let Ok(lsp_info) = self.lsp_info().await { - let node_id = lsp_info.pubkey; - let address = lsp_info.host; - let lsp_connected = self - .node_info() - .map(|info| info.connected_peers.iter().any(|e| e == node_id.as_str()))?; - if !lsp_connected { - debug!("connecting to lsp {}@{}", node_id.clone(), address.clone()); - self.node_api - .connect_peer(node_id.clone(), address.clone()) - .await - .map_err(|e| SdkError::ServiceConnectivity { - err: format!("(LSP: {node_id}) Failed to connect: {e}"), - })?; - } - debug!("connected to lsp {}@{}", node_id.clone(), address.clone()); - } - Ok(()) - } - - async fn on_payment_completed( - &self, - node_id: String, - invoice: Option, - amount_msat: Option, - payment_res: Result, - ) -> Result { - self.do_sync(payment_res.is_ok()).await?; - - match payment_res { - Ok(payment) => match self.persister.get_payment_by_hash(&payment.payment_hash)? { - Some(p) => { - self.notify_event_listeners(BreezEvent::PaymentSucceed { details: p.clone() }) - .await?; - Ok(p) - } - None => Err(SendPaymentError::Generic { - err: "Payment not found".into(), - }), - }, - Err(e) => { - if let Some(inv) = invoice.clone() { - self.persister.insert_payment_external_info( - &inv.payment_hash, - None, - None, - None, - None, - amount_msat.or(inv.amount_msat), - )?; - } - self.notify_event_listeners(BreezEvent::PaymentFailed { - details: PaymentFailedData { - error: e.to_string(), - node_id, - invoice, - }, - }) - .await?; - Err(e) - } - } - } - - async fn on_event(&self, e: BreezEvent) -> Result<()> { - debug!("breez services got event {:?}", e); - self.notify_event_listeners(e.clone()).await - } - - async fn notify_event_listeners(&self, e: BreezEvent) -> Result<()> { - if let Err(err) = self.btc_receive_swapper.on_event(e.clone()).await { - debug!( - "btc_receive_swapper failed to process event {:?}: {:?}", - e, err - ) - }; - if let Err(err) = self.btc_send_swapper.on_event(e.clone()).await { - debug!( - "btc_send_swapper failed to process event {:?}: {:?}", - e, err - ) - }; - - if self.event_listener.is_some() { - self.event_listener.as_ref().unwrap().on_event(e.clone()) - } - Ok(()) - } - - /// Convenience method to look up LSP info based on current LSP ID - pub async fn lsp_info(&self) -> SdkResult { - Ok(get_lsp(self.persister.clone(), self.lsp_api.clone()).await?) - } - - pub(crate) async fn start_node(&self) -> Result<()> { - self.node_api.start().await?; - Ok(()) - } - - /// Get the recommended fees for onchain transactions - pub async fn recommended_fees(&self) -> SdkResult { - Ok(self.chain_service.recommended_fees().await?) - } - - /// Get the full default config for a specific environment type - pub fn default_config( - env_type: EnvironmentType, - api_key: String, - node_config: NodeConfig, - ) -> Config { - match env_type { - EnvironmentType::Production => Config::production(api_key, node_config), - EnvironmentType::Staging => Config::staging(api_key, node_config), - } - } - - /// Get the static backup data from the peristent storage. - /// This data enables the user to recover the node in an external core ligntning node. - /// See here for instructions on how to recover using this data: https://docs.corelightning.org/docs/backup-and-recovery#backing-up-using-static-channel-backup - pub fn static_backup(req: StaticBackupRequest) -> SdkResult { - let storage = SqliteStorage::new(req.working_dir); - Ok(StaticBackupResponse { - backup: storage.get_static_backup()?, - }) - } - - /// Generates an url that can be used by a third part provider to buy Bitcoin with fiat currency. - /// - /// A user-selected [OpeningFeeParams] can be optionally set in the argument. If set, and the - /// operation requires a new channel, the SDK will try to use the given fee params. - pub async fn buy_bitcoin( - &self, - req: BuyBitcoinRequest, - ) -> Result { - let swap_info = self - .receive_onchain(ReceiveOnchainRequest { - opening_fee_params: req.opening_fee_params, - }) - .await?; - let url = match req.provider { - Moonpay => self.moonpay_api.buy_bitcoin_url(&swap_info).await?, - }; - - Ok(BuyBitcoinResponse { - url, - opening_fee_params: swap_info.channel_opening_fees, - }) + Ok(BuyBitcoinResponse { + url, + opening_fee_params: swap_info.channel_opening_fees, + }) } /// Starts the BreezServices background threads. @@ -1098,7 +802,7 @@ impl BreezServices { tokio::select! { backup_event = events_stream.recv() => { if let Ok(e) = backup_event { - if let Err(err) = cloned.notify_event_listeners(e).await { + if let Err(err) = cloned.notifier.notify_event_listeners(e).await { error!("error handling backup event: {:?}", err); } } @@ -1138,7 +842,7 @@ impl BreezServices { .insert_or_update_payments(&vec![payment.clone().unwrap()]); debug!("paid invoice was added to payments list {:?}", res); } - if let Err(e) = cloned.do_sync(true).await { + if let Err(e) = cloned.syncer.do_sync(true).await { error!("failed to sync after paid invoice: {:?}", e); } _ = cloned.on_event(BreezEvent::InvoicePaid { @@ -1327,95 +1031,6 @@ impl BreezServices { Ok(()) } - async fn lookup_chain_service_closing_outspend( - &self, - channel: crate::models::Channel, - ) -> Result> { - match channel.funding_outnum { - None => Ok(None), - Some(outnum) => { - // Find the output tx that was used to fund the channel - let outspends = self - .chain_service - .transaction_outspends(channel.funding_txid.clone()) - .await?; - - Ok(outspends.get(outnum as usize).cloned()) - } - } - } - - /// Chain service lookup of relevant channel closing fields (closed_at, closing_txid). - /// - /// Should be used sparingly because it involves a network lookup. - async fn lookup_channel_closing_data( - &self, - channel: &crate::models::Channel, - ) -> Result<(Option, Option)> { - let maybe_outspend = self - .lookup_chain_service_closing_outspend(channel.clone()) - .await?; - - let maybe_closed_at = maybe_outspend - .clone() - .and_then(|outspend| outspend.status) - .and_then(|s| s.block_time); - let maybe_closing_txid = maybe_outspend.and_then(|outspend| outspend.txid); - - Ok((maybe_closed_at, maybe_closing_txid)) - } - - async fn closed_channel_to_transaction( - &self, - channel: crate::models::Channel, - ) -> Result { - let (payment_time, closing_txid) = match (channel.closed_at, channel.closing_txid.clone()) { - (Some(closed_at), Some(closing_txid)) => (closed_at as i64, Some(closing_txid)), - (_, _) => { - // If any of the two closing-related fields are empty, we look them up and persist them - let (maybe_closed_at, maybe_closing_txid) = - self.lookup_channel_closing_data(&channel).await?; - - let processed_closed_at = match maybe_closed_at { - None => { - warn!("Blocktime could not be determined for from closing outspend, defaulting closed_at to epoch time"); - SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() - } - Some(block_time) => block_time, - }; - - let mut updated_channel = channel.clone(); - updated_channel.closed_at = Some(processed_closed_at); - // If no closing txid found, we persist it as None, so it will be looked-up next time - updated_channel.closing_txid = maybe_closing_txid.clone(); - self.persister.insert_or_update_channel(updated_channel)?; - - (processed_closed_at as i64, maybe_closing_txid) - } - }; - - Ok(Payment { - id: channel.funding_txid.clone(), - payment_type: PaymentType::ClosedChannel, - payment_time, - amount_msat: channel.spendable_msat, - fee_msat: 0, - status: match channel.state { - ChannelState::PendingClose => PaymentStatus::Pending, - _ => PaymentStatus::Complete, - }, - description: Some("Closed Channel".to_string()), - details: PaymentDetails::ClosedChannel { - data: ClosedChannelPaymentDetails { - short_channel_id: channel.short_channel_id, - state: channel.state, - funding_txid: channel.funding_txid, - closing_txid, - }, - }, - }) - } - /// Register for webhook callbacks at the given `webhook_url` whenever a new payment is received. /// /// More webhook types may be supported in the future. @@ -1668,6 +1283,37 @@ impl BreezServicesBuilder { unwrapped_node_api.clone(), )); + let notifier = Arc::new(ServiceNotifier { + btc_receive_swapper: btc_receive_swapper.clone(), + btc_send_swapper: btc_send_swapper.clone(), + event_listener, + }); + + let unwrapped_lsp_api = self.lsp_api.clone().unwrap_or_else(|| breez_server.clone()); + let syncer = Arc::new(ServiceSyncer { + node_api: unwrapped_node_api.clone(), + lsp_api: unwrapped_lsp_api.clone(), + chain_service: chain_service.clone(), + persister: persister.clone(), + notifier: notifier.clone(), + }); + + let payment_sender = Arc::new(PaymentSender { + config: self.config.clone(), + node_api: unwrapped_node_api.clone(), + persister: persister.clone(), + notifier: notifier.clone(), + syncer: syncer.clone(), + }); + + let lnurl_handler = Arc::new(ServiceLnUrlHandler { + config: self.config.clone(), + node_api: unwrapped_node_api.clone(), + persister: persister.clone(), + payment_receiver: payment_receiver.clone(), + payment_sender: payment_sender.clone(), + }); + // create a shutdown channel (sender and receiver) let (shutdown_sender, shutdown_receiver) = watch::channel::<()>(()); @@ -1675,7 +1321,7 @@ impl BreezServicesBuilder { let breez_services = Arc::new(BreezServices { started: Mutex::new(false), node_api: unwrapped_node_api.clone(), - lsp_api: self.lsp_api.clone().unwrap_or_else(|| breez_server.clone()), + lsp_api: unwrapped_lsp_api.clone(), fiat_api: self .fiat_api .clone() @@ -1686,10 +1332,13 @@ impl BreezServicesBuilder { .unwrap_or_else(|| breez_server.clone()), chain_service, persister: persister.clone(), + notifier, + syncer, btc_receive_swapper, btc_send_swapper, payment_receiver, - event_listener, + payment_sender, + lnurl_handler, backup_watcher: Arc::new(backup_watcher), shutdown_sender, shutdown_receiver, @@ -1798,13 +1447,233 @@ impl Interceptor for ApiKeyInterceptor { } } -/// Attempts to convert the phrase to a mnemonic, then to a seed. -/// -/// If the phrase is not a valid mnemonic, an error is returned. -pub fn mnemonic_to_seed(phrase: String) -> Result> { - let mnemonic = Mnemonic::from_phrase(&phrase, Language::English)?; - let seed = Seed::new(&mnemonic, ""); - Ok(seed.as_bytes().to_vec()) +#[tonic::async_trait] +pub trait Notifier: Send + Sync { + async fn notify_event_listeners(&self, e: BreezEvent) -> Result<()>; +} + +pub(crate) struct ServiceNotifier { + btc_receive_swapper: Arc, + btc_send_swapper: Arc, + event_listener: Option>, +} + +#[tonic::async_trait] +impl Notifier for ServiceNotifier { + async fn notify_event_listeners(&self, e: BreezEvent) -> Result<()> { + if let Err(err) = self.btc_receive_swapper.on_event(e.clone()).await { + debug!( + "btc_receive_swapper failed to process event {:?}: {:?}", + e, err + ) + }; + if let Err(err) = self.btc_send_swapper.on_event(e.clone()).await { + debug!( + "btc_send_swapper failed to process event {:?}: {:?}", + e, err + ) + }; + + if self.event_listener.is_some() { + self.event_listener.as_ref().unwrap().on_event(e.clone()) + } + Ok(()) + } +} + +#[tonic::async_trait] +pub trait Syncer: Send + Sync { + async fn do_sync(&self, balance_changed: bool) -> Result<()>; +} + +pub(crate) struct ServiceSyncer { + node_api: Arc, + lsp_api: Arc, + chain_service: Arc, + persister: Arc, + notifier: Arc, +} + +impl ServiceSyncer { + /// Connects to the selected LSP peer. If none is selected, this selects the first one from [`list_lsps`] and persists the selection. + async fn connect_lsp_peer(&self, node_pubkey: String) -> SdkResult<()> { + // Sets the LSP id, if not already set + if self.persister.get_lsp_id()?.is_none() { + if let Some(lsp) = self.lsp_api.list_lsps(node_pubkey).await?.first().cloned() { + self.persister.set_lsp_id(lsp.id)?; + } + } + if let Ok(lsp_info) = get_lsp(self.persister.clone(), self.lsp_api.clone()).await { + let node_id = lsp_info.pubkey; + let address = lsp_info.host; + let lsp_connected = self + .persister + .get_node_state()? + .ok_or(SdkError::Generic { + err: "Node info not found".into(), + }) + .map(|info| info.connected_peers.iter().any(|e| e == node_id.as_str()))?; + if !lsp_connected { + debug!("connecting to lsp {}@{}", node_id.clone(), address.clone()); + self.node_api + .connect_peer(node_id.clone(), address.clone()) + .await + .map_err(|e| SdkError::ServiceConnectivity { + err: format!("(LSP: {node_id}) Failed to connect: {e}"), + })?; + } + debug!("connected to lsp {}@{}", node_id.clone(), address.clone()); + } + Ok(()) + } + + async fn lookup_chain_service_closing_outspend( + &self, + channel: crate::models::Channel, + ) -> Result> { + match channel.funding_outnum { + None => Ok(None), + Some(outnum) => { + // Find the output tx that was used to fund the channel + let outspends = self + .chain_service + .transaction_outspends(channel.funding_txid.clone()) + .await?; + + Ok(outspends.get(outnum as usize).cloned()) + } + } + } + + /// Chain service lookup of relevant channel closing fields (closed_at, closing_txid). + /// + /// Should be used sparingly because it involves a network lookup. + async fn lookup_channel_closing_data( + &self, + channel: &crate::models::Channel, + ) -> Result<(Option, Option)> { + let maybe_outspend = self + .lookup_chain_service_closing_outspend(channel.clone()) + .await?; + + let maybe_closed_at = maybe_outspend + .clone() + .and_then(|outspend| outspend.status) + .and_then(|s| s.block_time); + let maybe_closing_txid = maybe_outspend.and_then(|outspend| outspend.txid); + + Ok((maybe_closed_at, maybe_closing_txid)) + } + + async fn closed_channel_to_transaction( + &self, + channel: crate::models::Channel, + ) -> Result { + let (payment_time, closing_txid) = match (channel.closed_at, channel.closing_txid.clone()) { + (Some(closed_at), Some(closing_txid)) => (closed_at as i64, Some(closing_txid)), + (_, _) => { + // If any of the two closing-related fields are empty, we look them up and persist them + let (maybe_closed_at, maybe_closing_txid) = + self.lookup_channel_closing_data(&channel).await?; + + let processed_closed_at = match maybe_closed_at { + None => { + warn!("Blocktime could not be determined for from closing outspend, defaulting closed_at to epoch time"); + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + } + Some(block_time) => block_time, + }; + + let mut updated_channel = channel.clone(); + updated_channel.closed_at = Some(processed_closed_at); + // If no closing txid found, we persist it as None, so it will be looked-up next time + updated_channel.closing_txid = maybe_closing_txid.clone(); + self.persister.insert_or_update_channel(updated_channel)?; + + (processed_closed_at as i64, maybe_closing_txid) + } + }; + + Ok(Payment { + id: channel.funding_txid.clone(), + payment_type: PaymentType::ClosedChannel, + payment_time, + amount_msat: channel.spendable_msat, + fee_msat: 0, + status: match channel.state { + ChannelState::PendingClose => PaymentStatus::Pending, + _ => PaymentStatus::Complete, + }, + description: Some("Closed Channel".to_string()), + details: PaymentDetails::ClosedChannel { + data: ClosedChannelPaymentDetails { + short_channel_id: channel.short_channel_id, + state: channel.state, + funding_txid: channel.funding_txid, + closing_txid, + }, + }, + }) + } +} + +#[tonic::async_trait] +impl Syncer for ServiceSyncer { + async fn do_sync(&self, balance_changed: bool) -> Result<()> { + let start = Instant::now(); + let node_pubkey = self.node_api.start().await?; + self.connect_lsp_peer(node_pubkey).await?; + + // First query the changes since last sync time. + let since_timestamp = self.persister.last_payment_timestamp().unwrap_or(0); + let new_data = &self + .node_api + .pull_changed(since_timestamp, balance_changed) + .await?; + + debug!( + "pull changed time={:?} {:?}", + since_timestamp, new_data.payments + ); + + // update node state and channels state + self.persister.set_node_state(&new_data.node_state)?; + + let channels_before_update = self.persister.list_channels()?; + self.persister.update_channels(&new_data.channels)?; + let channels_after_update = self.persister.list_channels()?; + + // Fetch the static backup if needed and persist it + if channels_before_update.len() != channels_after_update.len() { + info!("fetching static backup file from node"); + let backup = self.node_api.static_backup().await?; + self.persister.set_static_backup(backup)?; + } + + //fetch closed_channel and convert them to Payment items. + let mut closed_channel_payments: Vec = vec![]; + for closed_channel in + self.persister.list_channels()?.into_iter().filter(|c| { + c.state == ChannelState::Closed || c.state == ChannelState::PendingClose + }) + { + let closed_channel_tx = self.closed_channel_to_transaction(closed_channel).await?; + closed_channel_payments.push(closed_channel_tx); + } + + // update both closed channels and lightning transaction payments + let mut payments = closed_channel_payments; + payments.extend(new_data.payments.clone()); + self.persister.insert_or_update_payments(&payments)?; + + let duration = start.elapsed(); + info!("Sync duration: {:?}", duration); + + self.notifier + .notify_event_listeners(BreezEvent::Synced) + .await?; + Ok(()) + } } #[tonic::async_trait] @@ -1913,7 +1782,7 @@ impl Receiver for PaymentReceiver { .await?; info!("Invoice created {}", invoice); - let mut parsed_invoice = parse_invoice(invoice)?; + let mut parsed_invoice = parse_invoice(invoice, None)?; // check if the lsp hint already exists info!("Existing routing hints {:?}", parsed_invoice.routing_hints); @@ -1954,7 +1823,7 @@ impl Receiver for PaymentReceiver { let signed_invoice_with_hint = self.node_api.sign_invoice(raw_invoice_with_hint)?; info!("Signed invoice with hint = {}", signed_invoice_with_hint); - parsed_invoice = parse_invoice(&signed_invoice_with_hint)?; + parsed_invoice = parse_invoice(&signed_invoice_with_hint, None)?; } // register the payment at the lsp if needed @@ -2003,6 +1872,305 @@ impl Receiver for PaymentReceiver { } } +#[tonic::async_trait] +pub trait Sender: Send + Sync { + async fn send_payment( + &self, + req: SendPaymentRequest, + ) -> Result; + + async fn send_spontaneous_payment( + &self, + req: SendSpontaneousPaymentRequest, + ) -> Result; +} + +pub(crate) struct PaymentSender { + config: Config, + node_api: Arc, + persister: Arc, + notifier: Arc, + syncer: Arc, +} + +impl PaymentSender { + async fn on_payment_completed( + &self, + node_id: String, + invoice: Option, + amount_msat: Option, + payment_res: Result, + ) -> Result { + self.syncer.do_sync(payment_res.is_ok()).await?; + + match payment_res { + Ok(payment) => match self.persister.get_payment_by_hash(&payment.payment_hash)? { + Some(p) => { + self.notifier + .notify_event_listeners(BreezEvent::PaymentSucceed { details: p.clone() }) + .await?; + Ok(p) + } + None => Err(SendPaymentError::Generic { + err: "Payment not found".into(), + }), + }, + Err(e) => { + if let Some(inv) = invoice.clone() { + self.persister.insert_payment_external_info( + &inv.payment_hash, + None, + None, + None, + None, + amount_msat.or(inv.amount_msat), + )?; + } + self.notifier + .notify_event_listeners(BreezEvent::PaymentFailed { + details: PaymentFailedData { + error: e.to_string(), + node_id, + invoice, + }, + }) + .await?; + Err(e) + } + } + } +} + +#[tonic::async_trait] +impl Sender for PaymentSender { + async fn send_payment( + &self, + req: SendPaymentRequest, + ) -> Result { + self.node_api.start().await?; + let parsed_invoice = parse_invoice(req.bolt11.as_str(), Some(self.config.network))?; + let invoice_amount_msat = parsed_invoice.amount_msat.unwrap_or_default(); + let provided_amount_msat = req.amount_msat.unwrap_or_default(); + + // Ensure amount is provided for zero invoice + if provided_amount_msat == 0 && invoice_amount_msat == 0 { + return Err(SendPaymentError::InvalidAmount { + err: "Amount must be provided when paying a zero invoice".into(), + }); + } + + // Ensure amount is not provided for invoice that contains amount + if provided_amount_msat > 0 && invoice_amount_msat > 0 { + return Err(SendPaymentError::InvalidAmount { + err: "Amount should not be provided when paying a non zero invoice".into(), + }); + } + + match self + .persister + .get_completed_payment_by_hash(&parsed_invoice.payment_hash)? + { + Some(_) => Err(SendPaymentError::AlreadyPaid), + None => { + let payment_res = self + .node_api + .send_payment(req.bolt11.clone(), req.amount_msat) + .map_err(Into::into) + .await; + let payment = self + .on_payment_completed( + parsed_invoice.payee_pubkey.clone(), + Some(parsed_invoice), + req.amount_msat, + payment_res, + ) + .await?; + Ok(SendPaymentResponse { payment }) + } + } + } + + async fn send_spontaneous_payment( + &self, + req: SendSpontaneousPaymentRequest, + ) -> Result { + self.node_api.start().await?; + let payment_res = self + .node_api + .send_spontaneous_payment(req.node_id.clone(), req.amount_msat) + .map_err(Into::into) + .await; + let payment = self + .on_payment_completed(req.node_id, None, Some(req.amount_msat), payment_res) + .await?; + Ok(SendPaymentResponse { payment }) + } +} + +#[tonic::async_trait] +pub trait LnUrlHandler: Send + Sync { + async fn lnurl_auth( + &self, + req_data: LnUrlAuthRequestData, + ) -> Result; + + async fn lnurl_pay(&self, req: LnUrlPayRequest) -> Result; + + async fn lnurl_withdraw( + &self, + req: LnUrlWithdrawRequest, + ) -> Result; +} +pub(crate) struct ServiceLnUrlHandler { + config: Config, + node_api: Arc, + persister: Arc, + payment_receiver: Arc, + payment_sender: Arc, +} + +#[tonic::async_trait] +impl LnUrlHandler for ServiceLnUrlHandler { + async fn lnurl_auth( + &self, + req_data: LnUrlAuthRequestData, + ) -> Result { + Ok(perform_lnurl_auth(self.node_api.clone(), req_data).await?) + } + + async fn lnurl_pay(&self, req: LnUrlPayRequest) -> Result { + match validate_lnurl_pay( + req.amount_msat, + req.comment, + req.data.clone(), + self.config.network, + ) + .await? + { + ValidatedCallbackResponse::EndpointError { data: e } => { + Ok(LnUrlPayResult::EndpointError { data: e }) + } + ValidatedCallbackResponse::EndpointSuccess { data: cb } => { + let pay_req = SendPaymentRequest { + bolt11: cb.pr.clone(), + amount_msat: None, + }; + + let payment = match self.payment_sender.send_payment(pay_req).await { + Ok(p) => Ok(p), + Err(e) => match e { + SendPaymentError::InvalidInvoice { .. } => Err(e), + SendPaymentError::ServiceConnectivity { .. } => Err(e), + _ => { + let invoice = parse_invoice(cb.pr.as_str(), Some(self.config.network))?; + + return Ok(LnUrlPayResult::PayError { + data: LnUrlPayErrorData { + payment_hash: invoice.payment_hash, + reason: e.to_string(), + }, + }); + } + }, + }? + .payment; + let details = match &payment.details { + PaymentDetails::ClosedChannel { .. } => { + return Err(LnUrlPayError::Generic { + err: "Payment lookup found unexpected payment type".into(), + }); + } + PaymentDetails::Ln { data } => data, + }; + + let maybe_sa_processed: Option = match cb.success_action { + Some(sa) => { + let processed_sa = match sa { + // For AES, we decrypt the contents on the fly + Aes(data) => { + let preimage = sha256::Hash::from_str(&details.payment_preimage)?; + let preimage_arr: [u8; 32] = preimage.into_inner(); + + let decrypted = (data, &preimage_arr).try_into().map_err( + |e: anyhow::Error| LnUrlPayError::AesDecryptionFailed { + err: e.to_string(), + }, + )?; + SuccessActionProcessed::Aes { data: decrypted } + } + SuccessAction::Message(data) => { + SuccessActionProcessed::Message { data } + } + SuccessAction::Url(data) => SuccessActionProcessed::Url { data }, + }; + Some(processed_sa) + } + None => None, + }; + + // Store SA (if available) + LN Address in separate table, associated to payment_hash + self.persister.insert_payment_external_info( + &details.payment_hash, + maybe_sa_processed.as_ref(), + Some(req.data.metadata_str), + req.data.ln_address, + None, + None, + )?; + + Ok(LnUrlPayResult::EndpointSuccess { + data: LnUrlPaySuccessData { + payment_hash: details.payment_hash.clone(), + success_action: maybe_sa_processed, + }, + }) + } + } + } + + async fn lnurl_withdraw( + &self, + req: LnUrlWithdrawRequest, + ) -> Result { + let invoice = self + .payment_receiver + .receive_payment(ReceivePaymentRequest { + amount_msat: req.amount_msat, + description: req.description.unwrap_or_default(), + use_description_hash: Some(false), + ..Default::default() + }) + .await? + .ln_invoice; + + let lnurl_w_endpoint = req.data.callback.clone(); + let res = validate_lnurl_withdraw(req.data, invoice).await?; + + if let LnUrlWithdrawResult::Ok { ref data } = res { + // If endpoint was successfully called, store the LNURL-withdraw endpoint URL as metadata linked to the invoice + self.persister.insert_payment_external_info( + &data.invoice.payment_hash, + None, + None, + None, + Some(lnurl_w_endpoint), + None, + )?; + } + + Ok(res) + } +} + +/// Attempts to convert the phrase to a mnemonic, then to a seed. +/// +/// If the phrase is not a valid mnemonic, an error is returned. +pub fn mnemonic_to_seed(phrase: String) -> Result> { + let mnemonic = Mnemonic::from_phrase(&phrase, Language::English)?; + let seed = Seed::new(&mnemonic, ""); + Ok(seed.as_bytes().to_vec()) +} + /// Convenience method to look up LSP info based on current LSP ID async fn get_lsp( persister: Arc, diff --git a/libs/sdk-core/src/bridge_generated.rs b/libs/sdk-core/src/bridge_generated.rs index 49ff7fcc3..abc7415b5 100644 --- a/libs/sdk-core/src/bridge_generated.rs +++ b/libs/sdk-core/src/bridge_generated.rs @@ -1133,6 +1133,7 @@ impl support::IntoDart for LNInvoice { fn into_dart(self) -> support::DartAbi { vec![ self.bolt11.into_into_dart().into_dart(), + self.network.into_into_dart().into_dart(), self.payee_pubkey.into_into_dart().into_dart(), self.payment_hash.into_into_dart().into_dart(), self.description.into_dart(), diff --git a/libs/sdk-core/src/error.rs b/libs/sdk-core/src/error.rs index c9816c579..c08bb9add 100644 --- a/libs/sdk-core/src/error.rs +++ b/libs/sdk-core/src/error.rs @@ -67,6 +67,9 @@ pub enum LnUrlPayError { #[error("Invalid invoice: {err}")] InvalidInvoice { err: String }, + #[error("Invalid network: {err}")] + InvalidNetwork { err: String }, + #[error("Invalid uri: {err}")] InvalidUri { err: String }, @@ -105,15 +108,26 @@ impl From for LnUrlPayError { } } +impl From for LnUrlPayError { + fn from(value: InvoiceError) -> Self { + match value { + InvoiceError::InvalidNetwork(err) => Self::InvalidNetwork { + err: err.to_string(), + }, + _ => Self::InvalidInvoice { + err: value.to_string(), + }, + } + } +} + impl From for LnUrlPayError { fn from(value: LnUrlError) -> Self { match value { LnUrlError::InvalidUri(err) => Self::InvalidUri { err: err.to_string(), }, - LnUrlError::InvalidInvoice(err) => Self::InvalidInvoice { - err: err.to_string(), - }, + LnUrlError::InvalidInvoice(err) => err.into(), LnUrlError::ServiceConnectivity(err) => Self::ServiceConnectivity { err: err.to_string(), }, @@ -149,6 +163,7 @@ impl From for LnUrlPayError { SendPaymentError::AlreadyPaid => Self::AlreadyPaid, SendPaymentError::InvalidAmount { err } => Self::InvalidAmount { err }, SendPaymentError::InvalidInvoice { err } => Self::InvalidInvoice { err }, + SendPaymentError::InvalidNetwork { err } => Self::InvalidNetwork { err }, SendPaymentError::InvoiceExpired { err } => Self::InvoiceExpired { err }, SendPaymentError::PaymentFailed { err } => Self::PaymentFailed { err }, SendPaymentError::PaymentTimeout { err } => Self::PaymentTimeout { err }, @@ -571,6 +586,9 @@ pub enum SendPaymentError { #[error("Invalid invoice: {err}")] InvalidInvoice { err: String }, + #[error("Invalid network: {err}")] + InvalidNetwork { err: String }, + #[error("Invoice expired: {err}")] InvoiceExpired { err: String }, @@ -599,17 +617,14 @@ impl From for SendPaymentError { } impl From for SendPaymentError { - fn from(err: InvoiceError) -> Self { - Self::InvalidInvoice { - err: err.to_string(), - } - } -} - -impl From for LnUrlPayError { - fn from(err: InvoiceError) -> Self { - Self::InvalidInvoice { - err: err.to_string(), + fn from(value: InvoiceError) -> Self { + match value { + InvoiceError::InvalidNetwork(err) => Self::InvalidNetwork { + err: err.to_string(), + }, + _ => Self::InvalidInvoice { + err: value.to_string(), + }, } } } diff --git a/libs/sdk-core/src/greenlight/node_api.rs b/libs/sdk-core/src/greenlight/node_api.rs index f66b02f02..940920226 100644 --- a/libs/sdk-core/src/greenlight/node_api.rs +++ b/libs/sdk-core/src/greenlight/node_api.rs @@ -702,7 +702,7 @@ impl NodeAPI for Greenlight { } async fn send_pay(&self, bolt11: String, max_hops: u32) -> NodeResult { - let invoice = parse_invoice(&bolt11)?; + let invoice = parse_invoice(&bolt11, Some(self.sdk_config.network))?; let last_hop = invoice.routing_hints.first().and_then(|rh| rh.hops.first()); let mut client: node::ClnClient = self.get_node_client().await?; @@ -818,7 +818,7 @@ impl NodeAPI for Greenlight { ) -> NodeResult { let mut description = None; if !bolt11.is_empty() { - description = parse_invoice(&bolt11)?.description; + description = parse_invoice(&bolt11, Some(self.sdk_config.network))?.description; } let mut client: node::ClnClient = self.get_node_client().await?; @@ -1358,7 +1358,7 @@ impl TryFrom for Payment { type Error = NodeError; fn try_from(p: OffChainPayment) -> std::result::Result { - let ln_invoice = parse_invoice(&p.bolt11)?; + let ln_invoice = parse_invoice(&p.bolt11, None)?; Ok(Payment { id: hex::encode(p.payment_hash.clone()), payment_type: PaymentType::Received, @@ -1396,7 +1396,7 @@ impl TryFrom for Payment { fn try_from( invoice: gl_client::signer::model::greenlight::Invoice, ) -> std::result::Result { - let ln_invoice = parse_invoice(&invoice.bolt11)?; + let ln_invoice = parse_invoice(&invoice.bolt11, None)?; Ok(Payment { id: hex::encode(invoice.payment_hash.clone()), payment_type: PaymentType::Received, @@ -1443,7 +1443,7 @@ impl TryFrom for Payment { ) -> std::result::Result { let mut description = None; if !payment.bolt11.is_empty() { - description = parse_invoice(&payment.bolt11)?.description; + description = parse_invoice(&payment.bolt11, None)?.description; } let payment_amount = amount_to_msat(&payment.amount.clone().unwrap_or_default()); @@ -1486,7 +1486,7 @@ impl TryFrom for Payment { .bolt11 .as_ref() .ok_or(InvoiceError::Generic(anyhow!("No bolt11 invoice"))) - .and_then(|b| parse_invoice(b))?; + .and_then(|b| parse_invoice(b, None))?; Ok(Payment { id: hex::encode(invoice.payment_hash.clone()), payment_type: PaymentType::Received, @@ -1535,7 +1535,7 @@ impl TryFrom for Payment { .bolt11 .as_ref() .ok_or(InvoiceError::Generic(anyhow!("No bolt11 invoice"))) - .and_then(|b| parse_invoice(b)); + .and_then(|b| parse_invoice(b, None)); let payment_amount = payment .amount_msat .clone() diff --git a/libs/sdk-core/src/input_parser.rs b/libs/sdk-core/src/input_parser.rs index 475e7d439..c0bc1e765 100644 --- a/libs/sdk-core/src/input_parser.rs +++ b/libs/sdk-core/src/input_parser.rs @@ -156,7 +156,7 @@ pub async fn parse(input: &str) -> Result { invoice_param = querystring::querify(query) .iter() .find(|(key, _)| key == &"lightning") - .map(|(_, value)| parse_invoice(value)) + .map(|(_, value)| parse_invoice(value, None)) .transpose()?; } @@ -168,7 +168,7 @@ pub async fn parse(input: &str) -> Result { }; } - if let Ok(invoice) = parse_invoice(input) { + if let Ok(invoice) = parse_invoice(input, None) { return Ok(Bolt11 { invoice }); } diff --git a/libs/sdk-core/src/invoice.rs b/libs/sdk-core/src/invoice.rs index a3ac71244..e2d02f03c 100644 --- a/libs/sdk-core/src/invoice.rs +++ b/libs/sdk-core/src/invoice.rs @@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize}; use std::str::FromStr; use std::time::{SystemTimeError, UNIX_EPOCH}; +use crate::Network; + pub type InvoiceResult = Result; #[derive(Debug, thiserror::Error)] @@ -16,6 +18,9 @@ pub enum InvoiceError { #[error("Generic: {0}")] Generic(#[from] anyhow::Error), + #[error("Invalid network: {0}")] + InvalidNetwork(anyhow::Error), + #[error("Validation: {0}")] Validation(anyhow::Error), } @@ -60,6 +65,7 @@ impl From for InvoiceError { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct LNInvoice { pub bolt11: String, + pub network: Network, pub payee_pubkey: String, pub payment_hash: String, pub description: Option, @@ -189,8 +195,17 @@ pub fn add_lsp_routing_hints( Ok(invoice_builder.build_raw()?) } +pub fn validate_network(invoice: Invoice, network: Network) -> InvoiceResult<()> { + match invoice.network() == network.into() { + true => Ok(()), + false => Err(InvoiceError::InvalidNetwork(anyhow!( + "Invoice network does not match config" + ))), + } +} + /// Parse a BOLT11 payment request and return a structure contains the parsed fields. -pub fn parse_invoice(bolt11: &str) -> InvoiceResult { +pub fn parse_invoice(bolt11: &str, network: Option) -> InvoiceResult { if bolt11.trim().is_empty() { return Err(InvoiceError::Validation(anyhow!( "bolt11 is an empty string" @@ -206,6 +221,11 @@ pub fn parse_invoice(bolt11: &str) -> InvoiceResult { // make sure signature is valid invoice.check_signature()?; + // Make sure we are on the same network + if let Some(network) = network { + validate_network(invoice.clone(), network)?; + } + // Try to take payee pubkey from the tagged fields, if doesn't exist recover it from the signature let payee_pubkey: String = match invoice.payee_pub_key() { Some(key) => key.serialize().encode_hex::(), @@ -221,6 +241,7 @@ pub fn parse_invoice(bolt11: &str) -> InvoiceResult { // return the parsed invoice let ln_invoice = LNInvoice { bolt11: bolt11.to_string(), + network: invoice.network().into(), payee_pubkey, expiry: invoice.expiry_time().as_secs(), amount_msat: invoice.amount_milli_satoshis(), @@ -248,7 +269,7 @@ mod tests { #[test] fn test_parse_invoice() { let payreq = String::from("lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhmnsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhhd2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz"); - let res = parse_invoice(&payreq).unwrap(); + let res = parse_invoice(&payreq, None).unwrap(); let private_key_vec = hex::decode("3e171115f50b2c355836dc026a6d54d525cf0d796eb50b3460a205d25c9d38fd") @@ -271,4 +292,45 @@ mod tests { let encoded = add_lsp_routing_hints(payreq, Some(route_hint), 100).unwrap(); print!("{encoded:?}"); } + + #[test] + fn test_parse_invoice_network() { + let payreq = String::from("lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhmnsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhhd2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz"); + let res = parse_invoice(&payreq, Some(Network::Bitcoin)).unwrap(); + + let private_key_vec = + hex::decode("3e171115f50b2c355836dc026a6d54d525cf0d796eb50b3460a205d25c9d38fd") + .unwrap(); + let mut private_key: [u8; 32] = Default::default(); + private_key.copy_from_slice(&private_key_vec[0..32]); + let hint_hop = RouteHintHop { + src_node_id: res.payee_pubkey, + short_channel_id: 1234, + fees_base_msat: 1000, + fees_proportional_millionths: 100, + cltv_expiry_delta: 2000, + htlc_minimum_msat: Some(3000), + htlc_maximum_msat: Some(4000), + }; + let route_hint = RouteHint { + hops: vec![hint_hop], + }; + + let encoded = add_lsp_routing_hints(payreq, Some(route_hint), 100).unwrap(); + print!("{encoded:?}"); + } + + #[test] + fn test_parse_invoice_invalid_bitcoin_network() { + let payreq = String::from("lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhmnsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhhd2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz"); + + assert!(parse_invoice(&payreq, Some(Network::Testnet)).is_err()); + } + + #[test] + fn test_parse_invoice_invalid_testnet_network() { + let payreq = String::from("lntb15u1pj53l9tpp5p7kjsjcv3eqa39upytmj6k7ac8rqvdffyqr4um98pq5n4ppwxvnsdpzxysy2umswfjhxum0yppk76twypgxzmnwvyxqrrsscqp79qy9qsqsp53xw4x5ezpzvnheff9mrt0ju72u5a5dnxyh4rq6gtweufv9650d4qwqj3ds5xfg4pxc9h7a2g43fmntr4tt322jzujsycvuvury50u994kzr8539qf658hrp07hyz634qpvkeh378wnvf7lddp2x7yfgyk9cp7f7937"); + + assert!(parse_invoice(&payreq, Some(Network::Bitcoin)).is_err()); + } } diff --git a/libs/sdk-core/src/lnurl/pay.rs b/libs/sdk-core/src/lnurl/pay.rs index e666ca767..14e61606e 100644 --- a/libs/sdk-core/src/lnurl/pay.rs +++ b/libs/sdk-core/src/lnurl/pay.rs @@ -1,8 +1,8 @@ use crate::invoice::parse_invoice; use crate::lnurl::maybe_replace_host_with_mockito_test_host; use crate::lnurl::pay::model::{CallbackResponse, SuccessAction, ValidatedCallbackResponse}; -use crate::LnUrlErrorData; use crate::{ensure_sdk, input_parser::*}; +use crate::{LnUrlErrorData, Network}; use anyhow::anyhow; use bitcoin::hashes::{sha256, Hash}; use std::str::FromStr; @@ -20,6 +20,7 @@ pub(crate) async fn validate_lnurl_pay( user_amount_msat: u64, comment: Option, req_data: LnUrlPayRequestData, + network: Network, ) -> LnUrlResult { validate_user_input( user_amount_msat, @@ -46,7 +47,12 @@ pub(crate) async fn validate_lnurl_pay( } } - validate_invoice(user_amount_msat, &callback_resp.pr, &req_data)?; + validate_invoice( + user_amount_msat, + &callback_resp.pr, + &req_data, + Some(network), + )?; Ok(ValidatedCallbackResponse::EndpointSuccess { data: callback_resp, }) @@ -104,8 +110,9 @@ fn validate_invoice( user_amount_msat: u64, bolt11: &str, data: &LnUrlPayRequestData, + network: Option, ) -> LnUrlResult<()> { - let invoice = parse_invoice(bolt11)?; + let invoice = parse_invoice(bolt11, network)?; match invoice.description_hash { None => { @@ -668,10 +675,59 @@ mod tests { let inv = rand_invoice_with_description_hash(temp_desc.clone())?; let payreq: String = rand_invoice_with_description_hash(temp_desc)?.to_string(); - assert!(validate_invoice(inv.amount_milli_satoshis().unwrap(), &payreq, &req).is_ok()); assert!( - validate_invoice(inv.amount_milli_satoshis().unwrap() + 1000, &payreq, &req).is_err() + validate_invoice(inv.amount_milli_satoshis().unwrap(), &payreq, &req, None).is_ok() ); + assert!(validate_invoice( + inv.amount_milli_satoshis().unwrap() + 1000, + &payreq, + &req, + None + ) + .is_err()); + + Ok(()) + } + + #[test] + fn test_lnurl_pay_validate_invoice_network() -> Result<()> { + let req = get_test_pay_req_data(0, 50_000, 0); + let temp_desc = req.metadata_str.clone(); + let inv = rand_invoice_with_description_hash(temp_desc.clone())?; + let payreq: String = rand_invoice_with_description_hash(temp_desc)?.to_string(); + + assert!(validate_invoice( + inv.amount_milli_satoshis().unwrap(), + &payreq, + &req, + Some(Network::Bitcoin) + ) + .is_ok()); + assert!(validate_invoice( + inv.amount_milli_satoshis().unwrap() + 1000, + &payreq, + &req, + Some(Network::Bitcoin), + ) + .is_err()); + + Ok(()) + } + + #[test] + fn test_lnurl_pay_validate_invoice_wrong_network() -> Result<()> { + let req = get_test_pay_req_data(0, 25_000, 0); + let temp_desc = req.metadata_str.clone(); + let inv = rand_invoice_with_description_hash(temp_desc.clone())?; + let payreq: String = rand_invoice_with_description_hash(temp_desc)?.to_string(); + + assert!(validate_invoice( + inv.amount_milli_satoshis().unwrap(), + &payreq, + &req, + Some(Network::Testnet) + ) + .is_err()); Ok(()) } diff --git a/libs/sdk-core/src/lnurl/withdraw.rs b/libs/sdk-core/src/lnurl/withdraw.rs index 438d1613c..ef805b8ad 100644 --- a/libs/sdk-core/src/lnurl/withdraw.rs +++ b/libs/sdk-core/src/lnurl/withdraw.rs @@ -119,7 +119,7 @@ mod tests { #[tokio::test] async fn test_lnurl_withdraw_success() -> Result<()> { let invoice_str = "lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhmnsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhhd2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz"; - let req_invoice = crate::invoice::parse_invoice(invoice_str)?; + let req_invoice = crate::invoice::parse_invoice(invoice_str, None)?; let withdraw_req = get_test_withdraw_req_data(0, 100); let _m = mock_lnurl_withdraw_callback(&withdraw_req, &req_invoice, None)?; @@ -135,7 +135,7 @@ mod tests { #[tokio::test] async fn test_lnurl_withdraw_validate_amount_failure() -> Result<()> { let invoice_str = "lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhmnsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhhd2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz"; - let invoice = crate::invoice::parse_invoice(invoice_str)?; + let invoice = crate::invoice::parse_invoice(invoice_str, None)?; let withdraw_req = get_test_withdraw_req_data(0, 1); // Fail validation before even calling the endpoint (no mock needed) @@ -149,7 +149,7 @@ mod tests { #[tokio::test] async fn test_lnurl_withdraw_endpoint_failure() -> Result<()> { let invoice_str = "lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhmnsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhhd2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz"; - let invoice = crate::invoice::parse_invoice(invoice_str)?; + let invoice = crate::invoice::parse_invoice(invoice_str, None)?; let withdraw_req = get_test_withdraw_req_data(0, 100); // Generic error reported by endpoint diff --git a/libs/sdk-core/src/models.rs b/libs/sdk-core/src/models.rs index aa2e0c932..4b9321e73 100644 --- a/libs/sdk-core/src/models.rs +++ b/libs/sdk-core/src/models.rs @@ -506,7 +506,7 @@ pub struct GreenlightCredentials { } /// The different supported bitcoin networks -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, Display, Eq, PartialEq, Serialize, Deserialize)] pub enum Network { /// Mainnet Bitcoin, diff --git a/libs/sdk-core/src/persist/transactions.rs b/libs/sdk-core/src/persist/transactions.rs index b10e206de..705942aa6 100644 --- a/libs/sdk-core/src/persist/transactions.rs +++ b/libs/sdk-core/src/persist/transactions.rs @@ -485,7 +485,7 @@ fn test_ln_transactions() -> PersistResult<(), Box> { description: Some("desc".to_string()), details: PaymentDetails::Ln { data: LnPaymentDetails { - payment_hash: hex::encode(payment_hash_with_swap_info.clone()), + payment_hash: hex::encode(payment_hash_with_swap_info), label: "label".to_string(), destination_pubkey: "pubkey".to_string(), payment_preimage: "payment_preimage".to_string(), diff --git a/libs/sdk-core/src/test_utils.rs b/libs/sdk-core/src/test_utils.rs index 1df698599..e7786d854 100644 --- a/libs/sdk-core/src/test_utils.rs +++ b/libs/sdk-core/src/test_utils.rs @@ -257,7 +257,7 @@ impl Receiver for MockReceiver { _request: ReceivePaymentRequest, ) -> Result { Ok(crate::ReceivePaymentResponse { - ln_invoice: parse_invoice(&self.bolt11)?, + ln_invoice: parse_invoice(&self.bolt11, None)?, opening_fee_params: _request.opening_fee_params, opening_fee_msat: None, }) @@ -728,7 +728,7 @@ pub fn create_invoice( } let raw_invoice = invoice_builder.build_raw().unwrap(); - parse_invoice(&sign_invoice(raw_invoice)).unwrap() + parse_invoice(&sign_invoice(raw_invoice), None).unwrap() } fn sign_invoice(invoice: RawInvoice) -> String { diff --git a/libs/sdk-flutter/lib/bridge_generated.dart b/libs/sdk-flutter/lib/bridge_generated.dart index 712cf1054..a748afcdb 100644 --- a/libs/sdk-flutter/lib/bridge_generated.dart +++ b/libs/sdk-flutter/lib/bridge_generated.dart @@ -618,6 +618,7 @@ class ListPaymentsRequest { /// Wrapper for a BOLT11 LN invoice class LNInvoice { final String bolt11; + final Network network; final String payeePubkey; final String paymentHash; final String? description; @@ -631,6 +632,7 @@ class LNInvoice { const LNInvoice({ required this.bolt11, + required this.network, required this.payeePubkey, required this.paymentHash, this.description, @@ -2862,19 +2864,20 @@ class BreezSdkCoreImpl implements BreezSdkCore { LNInvoice _wire2api_ln_invoice(dynamic raw) { final arr = raw as List; - if (arr.length != 11) throw Exception('unexpected arr length: expect 11 but see ${arr.length}'); + if (arr.length != 12) throw Exception('unexpected arr length: expect 12 but see ${arr.length}'); return LNInvoice( bolt11: _wire2api_String(arr[0]), - payeePubkey: _wire2api_String(arr[1]), - paymentHash: _wire2api_String(arr[2]), - description: _wire2api_opt_String(arr[3]), - descriptionHash: _wire2api_opt_String(arr[4]), - amountMsat: _wire2api_opt_box_autoadd_u64(arr[5]), - timestamp: _wire2api_u64(arr[6]), - expiry: _wire2api_u64(arr[7]), - routingHints: _wire2api_list_route_hint(arr[8]), - paymentSecret: _wire2api_uint_8_list(arr[9]), - minFinalCltvExpiryDelta: _wire2api_u64(arr[10]), + network: _wire2api_network(arr[1]), + payeePubkey: _wire2api_String(arr[2]), + paymentHash: _wire2api_String(arr[3]), + description: _wire2api_opt_String(arr[4]), + descriptionHash: _wire2api_opt_String(arr[5]), + amountMsat: _wire2api_opt_box_autoadd_u64(arr[6]), + timestamp: _wire2api_u64(arr[7]), + expiry: _wire2api_u64(arr[8]), + routingHints: _wire2api_list_route_hint(arr[9]), + paymentSecret: _wire2api_uint_8_list(arr[10]), + minFinalCltvExpiryDelta: _wire2api_u64(arr[11]), ); } diff --git a/libs/sdk-react-native/android/src/main/java/com/breezsdk/BreezSDKMapper.kt b/libs/sdk-react-native/android/src/main/java/com/breezsdk/BreezSDKMapper.kt index 9bca21a38..0443cc5b2 100644 --- a/libs/sdk-react-native/android/src/main/java/com/breezsdk/BreezSDKMapper.kt +++ b/libs/sdk-react-native/android/src/main/java/com/breezsdk/BreezSDKMapper.kt @@ -665,6 +665,7 @@ fun asLnInvoice(lnInvoice: ReadableMap): LnInvoice? { lnInvoice, arrayOf( "bolt11", + "network", "payeePubkey", "paymentHash", "timestamp", @@ -678,6 +679,7 @@ fun asLnInvoice(lnInvoice: ReadableMap): LnInvoice? { return null } val bolt11 = lnInvoice.getString("bolt11")!! + val network = lnInvoice.getString("network")?.let { asNetwork(it) }!! val payeePubkey = lnInvoice.getString("payeePubkey")!! val paymentHash = lnInvoice.getString("paymentHash")!! val description = if (hasNonNullKey(lnInvoice, "description")) lnInvoice.getString("description") else null @@ -690,6 +692,7 @@ fun asLnInvoice(lnInvoice: ReadableMap): LnInvoice? { val minFinalCltvExpiryDelta = lnInvoice.getDouble("minFinalCltvExpiryDelta").toULong() return LnInvoice( bolt11, + network, payeePubkey, paymentHash, description, @@ -706,6 +709,7 @@ fun asLnInvoice(lnInvoice: ReadableMap): LnInvoice? { fun readableMapOf(lnInvoice: LnInvoice): ReadableMap { return readableMapOf( "bolt11" to lnInvoice.bolt11, + "network" to lnInvoice.network.name.lowercase(), "payeePubkey" to lnInvoice.payeePubkey, "paymentHash" to lnInvoice.paymentHash, "description" to lnInvoice.description, diff --git a/libs/sdk-react-native/ios/BreezSDKMapper.swift b/libs/sdk-react-native/ios/BreezSDKMapper.swift index 637825638..a552c43d6 100644 --- a/libs/sdk-react-native/ios/BreezSDKMapper.swift +++ b/libs/sdk-react-native/ios/BreezSDKMapper.swift @@ -604,6 +604,9 @@ class BreezSDKMapper { static func asLnInvoice(lnInvoice: [String: Any?]) throws -> LnInvoice { guard let bolt11 = lnInvoice["bolt11"] as? String else { throw SdkError.Generic(message: "Missing mandatory field bolt11 for type LnInvoice") } + guard let networkTmp = lnInvoice["network"] as? String else { throw SdkError.Generic(message: "Missing mandatory field network for type LnInvoice") } + let network = try asNetwork(network: networkTmp) + guard let payeePubkey = lnInvoice["payeePubkey"] as? String else { throw SdkError.Generic(message: "Missing mandatory field payeePubkey for type LnInvoice") } guard let paymentHash = lnInvoice["paymentHash"] as? String else { throw SdkError.Generic(message: "Missing mandatory field paymentHash for type LnInvoice") } let description = lnInvoice["description"] as? String @@ -619,6 +622,7 @@ class BreezSDKMapper { return LnInvoice( bolt11: bolt11, + network: network, payeePubkey: payeePubkey, paymentHash: paymentHash, description: description, @@ -635,6 +639,7 @@ class BreezSDKMapper { static func dictionaryOf(lnInvoice: LnInvoice) -> [String: Any?] { return [ "bolt11": lnInvoice.bolt11, + "network": valueOf(network: lnInvoice.network), "payeePubkey": lnInvoice.payeePubkey, "paymentHash": lnInvoice.paymentHash, "description": lnInvoice.description == nil ? nil : lnInvoice.description, diff --git a/libs/sdk-react-native/src/index.ts b/libs/sdk-react-native/src/index.ts index 6b6cf01b9..c5ae19d00 100644 --- a/libs/sdk-react-native/src/index.ts +++ b/libs/sdk-react-native/src/index.ts @@ -114,6 +114,7 @@ export type InvoicePaidDetails = { export type LnInvoice = { bolt11: string + network: Network payeePubkey: string paymentHash: string description?: string