diff --git a/src/amount.rs b/src/amount.rs index 8a65be05..2a2595b6 100644 --- a/src/amount.rs +++ b/src/amount.rs @@ -44,7 +44,7 @@ impl AsSats for u32 { } /// A fiat value accompanied by the exchange rate that was used to get it. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub struct FiatValue { /// Fiat amount denominated in the currencies' minor units. For most fiat currencies, the minor unit is the cent. pub minor_units: u64, @@ -55,7 +55,7 @@ pub struct FiatValue { } /// A sat amount accompanied by its fiat value in a specific fiat currency -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub struct Amount { pub sats: u64, pub fiat: Option, diff --git a/src/data_store.rs b/src/data_store.rs index 1b5b04dd..4d3c4343 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -122,6 +122,63 @@ impl DataStore { } } + pub fn store_created_invoice(&self, hash: &str, invoice: &str) -> Result<()> { + self.conn + .execute( + "\ + INSERT INTO created_invoices (hash, invoice)\ + VALUES (?1, ?2)\ + ", + [hash, invoice], + ) + .map_to_permanent_failure("Failed to store created invoice to local db")?; + Ok(()) + } + + pub fn retrieve_created_invoices(&self, number_of_invoices: u32) -> Result> { + let mut statement = self + .conn + .prepare( + "\ + SELECT invoice, id \ + FROM created_invoices \ + ORDER BY id DESC \ + LIMIT ?1; + ", + ) + .map_to_permanent_failure("Failed to retrieve created invoice from local db")?; + + let invoice_iter = statement + .query_map([number_of_invoices], |r| r.get::(0)) + .map_to_permanent_failure("Failed to bind parameter to prepared SQL query")?; + + let mut invoices = Vec::new(); + for rate in invoice_iter { + invoices.push(rate.map_to_permanent_failure("Corrupted db")?); + } + Ok(invoices) + } + + pub fn retrieve_created_invoice_by_hash(&self, hash: &str) -> Result> { + let mut statement = self + .conn + .prepare( + "\ + SELECT invoice \ + FROM created_invoices \ + WHERE hash=?1; + ", + ) + .map_to_permanent_failure("Failed to retrieve created invoice from local db")?; + + let mut invoice_iter = statement + .query_map([hash], |r| r.get::(0)) + .map_to_permanent_failure("Failed to bind parameter to prepared SQL query")? + .filter_map(|i| i.ok()); + + Ok(invoice_iter.next()) + } + pub fn update_exchange_rate( &self, currency_code: &str, @@ -780,6 +837,53 @@ mod tests { ); } + #[test] + fn test_invoice_persistence() { + let db_name = String::from("invoice_persistence.db3"); + reset_db(&db_name); + let data_store = DataStore::new(&format!("{TEST_DB_PATH}/{db_name}")).unwrap(); + + assert!(data_store.retrieve_created_invoices(5).unwrap().is_empty()); + + data_store + .store_created_invoice("hash1", "invoice1") + .unwrap(); + assert_eq!( + data_store.retrieve_created_invoices(5).unwrap(), + vec!["invoice1".to_string()] + ); + + data_store + .store_created_invoice("hash2", "invoice2") + .unwrap(); + assert_eq!( + data_store.retrieve_created_invoices(5).unwrap(), + vec!["invoice2".to_string(), "invoice1".to_string()] + ); + + assert_eq!( + data_store.retrieve_created_invoices(1).unwrap(), + vec!["invoice2".to_string()] + ); + + assert!(data_store + .retrieve_created_invoice_by_hash("hash0") + .unwrap() + .is_none()); + assert_eq!( + data_store + .retrieve_created_invoice_by_hash("hash1") + .unwrap(), + Some("invoice1".into()) + ); + assert_eq!( + data_store + .retrieve_created_invoice_by_hash("hash2") + .unwrap(), + Some("invoice2".into()) + ); + } + fn reset_db(db_name: &str) { let _ = fs::create_dir(TEST_DB_PATH); let _ = fs::remove_file(format!("{TEST_DB_PATH}/{db_name}")); diff --git a/src/invoice_details.rs b/src/invoice_details.rs index 0768f6f2..e37bb986 100644 --- a/src/invoice_details.rs +++ b/src/invoice_details.rs @@ -6,7 +6,7 @@ use breez_sdk_core::LNInvoice; use std::time::{Duration, SystemTime}; /// Information embedded in an invoice -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub struct InvoiceDetails { /// The BOLT-11 invoice. pub invoice: String, diff --git a/src/lib.rs b/src/lib.rs index 28ff8743..cd096626 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,7 +78,10 @@ use iban::Iban; use log::{info, trace}; use logger::init_logger_once; use perro::Error::RuntimeError; -use perro::{permanent_failure, runtime_error, MapToError, OptionToError, ResultTrait}; +use perro::{ + invalid_input, permanent_failure, runtime_error, MapToError, OptionToError, ResultTrait, +}; +use std::cmp::Reverse; use std::collections::HashSet; use std::path::Path; use std::str::FromStr; @@ -658,6 +661,13 @@ impl LightningNode { self.store_payment_info(&response.ln_invoice.payment_hash, None) .map_to_permanent_failure("Failed to persist payment info")?; // TODO: persist metadata + self.data_store + .lock_unwrap() + .store_created_invoice( + &response.ln_invoice.payment_hash, + &response.ln_invoice.bolt11, + ) + .map_to_permanent_failure("Failed to persist created invoice")?; Ok(InvoiceDetails::from_ln_invoice( response.ln_invoice, @@ -835,7 +845,8 @@ impl LightningNode { limit: None, offset: None, }; - self.rt + let breez_payments = self + .rt .handle() .block_on(self.sdk.list_payments(list_payments_request)) .map_to_runtime_error(RuntimeErrorCode::NodeUnavailable, "Failed to list payments")? @@ -843,7 +854,29 @@ impl LightningNode { .filter(|p| p.payment_type != breez_sdk_core::PaymentType::ClosedChannel) .take(number_of_payments as usize) .map(|p| self.payment_from_breez_payment(p)) - .collect::>>() + .collect::>>()?; + + let breez_payment_invoices: HashSet = breez_payments + .iter() + .map(|p| p.invoice_details.invoice.clone()) + .collect(); + let created_invoices = self + .data_store + .lock_unwrap() + .retrieve_created_invoices(number_of_payments)?; + let mut pending_inbound_payments = created_invoices + .into_iter() + .filter(|i| !breez_payment_invoices.contains(i)) + .map(|i| self.payment_from_created_invoice(&i)) + .collect::>>()?; + + let mut payments = breez_payments; + payments.append(&mut pending_inbound_payments); + payments.sort_by_key(|p| Reverse(p.created_at.time)); + Ok(payments + .into_iter() + .take(number_of_payments as usize) + .collect()) } /// Get a payment given its payment hash @@ -851,17 +884,25 @@ impl LightningNode { /// Parameters: /// * `hash` - hex representation of payment hash pub fn get_payment(&self, hash: String) -> Result { - let breez_payment = self + if let Some(breez_payment) = self .rt .handle() - .block_on(self.sdk.payment_by_hash(hash)) + .block_on(self.sdk.payment_by_hash(hash.clone())) .map_to_runtime_error( RuntimeErrorCode::NodeUnavailable, "Failed to get payment by hash", )? - .ok_or_invalid_input("Invalid hash: no payment with provided hash was found")?; - - self.payment_from_breez_payment(breez_payment) + { + self.payment_from_breez_payment(breez_payment) + } else if let Some(invoice) = self + .data_store + .lock_unwrap() + .retrieve_created_invoice_by_hash(&hash)? + { + self.payment_from_created_invoice(&invoice) + } else { + invalid_input!("No payment with provided hash was found"); + } } fn payment_from_breez_payment( @@ -875,17 +916,36 @@ impl LightningNode { ), }; + let invoice = parse_invoice(&payment_details.bolt11) + .map_to_permanent_failure("Invalid invoice provided by the Breez SDK")?; + let invoice_details = InvoiceDetails::from_ln_invoice(invoice.clone(), &None); + let local_payment_data = self .data_store .lock_unwrap() .retrieve_payment_info(&payment_details.payment_hash)?; + // Use invoice timestamp for receiving payments and breez_payment.payment_time for sending ones + // Reasoning: for receiving payments, Breez returns the time the invoice was paid. Given that + // now we show pending invoices, this can result in a receiving payment jumping around in the + // list when it gets paid. + let time = match breez_payment.payment_type { + breez_sdk_core::PaymentType::Sent => { + unix_timestamp_to_system_time(breez_payment.payment_time as u64) + } + breez_sdk_core::PaymentType::Received => invoice_details.creation_timestamp, + breez_sdk_core::PaymentType::ClosedChannel => { + permanent_failure!( + "Current interface doesn't support PaymentDetails::ClosedChannel" + ) + } + }; let (exchange_rate, time, offer) = match local_payment_data { None => { let exchange_rate = self.get_exchange_rate(); let user_preferences = self.user_preferences.lock_unwrap(); let time = TzTime { - time: unix_timestamp_to_system_time(breez_payment.payment_time as u64), + time, timezone_id: user_preferences.timezone_config.timezone_id.clone(), timezone_utc_offset_secs: user_preferences .timezone_config @@ -897,7 +957,7 @@ impl LightningNode { Some(d) => { let exchange_rate = Some(d.exchange_rate); let time = TzTime { - time: unix_timestamp_to_system_time(breez_payment.payment_time as u64), + time, timezone_id: d.user_preferences.timezone_config.timezone_id, timezone_utc_offset_secs: d .user_preferences @@ -948,8 +1008,6 @@ impl LightningNode { PaymentStatus::Failed => PaymentState::Failed, }; - let invoice = parse_invoice(&payment_details.bolt11) - .map_to_permanent_failure("Invalid invoice provided by the Breez SDK")?; let invoice_details = InvoiceDetails::from_ln_invoice(invoice, &exchange_rate); let description = invoice_details.description.clone(); @@ -971,6 +1029,58 @@ impl LightningNode { }) } + fn payment_from_created_invoice(&self, invoice: &str) -> Result { + let invoice = parse_invoice(invoice) + .map_to_permanent_failure("Invalid invoice provided by the Breez SDK")?; + let invoice_details = InvoiceDetails::from_ln_invoice(invoice, &None); + + let payment_state = if SystemTime::now() > invoice_details.expiry_timestamp { + PaymentState::InvoiceExpired + } else { + PaymentState::Created + }; + + let local_payment_data = self + .data_store + .lock_unwrap() + .retrieve_payment_info(&invoice_details.payment_hash)? + .ok_or_permanent_failure("Locally created invoice doesn't have local payment data")?; + let exchange_rate = Some(local_payment_data.exchange_rate); + let time = TzTime { + time: invoice_details.creation_timestamp, // for receiving payments, we use the invoice timestamp + timezone_id: local_payment_data + .user_preferences + .timezone_config + .timezone_id, + timezone_utc_offset_secs: local_payment_data + .user_preferences + .timezone_config + .timezone_utc_offset_secs, + }; + + Ok(Payment { + payment_type: PaymentType::Receiving, + payment_state, + fail_reason: None, + hash: invoice_details.payment_hash.clone(), + amount: invoice_details + .amount + .clone() + .ok_or_permanent_failure("Locally created invoice doesn't include an amount")? + .sats + .as_sats() + .to_amount_down(&exchange_rate), + invoice_details: invoice_details.clone(), + created_at: time, + description: invoice_details.description, + preimage: None, + network_fees: None, + lsp_fees: None, + offer: None, + metadata: String::new(), // TODO: retrieve metadata from local db + }) + } + /// Call the method when the app goes to foreground, such that the user can interact with it. /// The library starts running the background tasks more frequently to improve user experience. pub fn foreground(&self) { diff --git a/src/migrations.rs b/src/migrations.rs index 25d29c02..a0e3964e 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -53,6 +53,15 @@ const MIGRATION_02_FUNDS_MIGRATION_STATUS: &str = " const MIGRATION_03_OFFER_ERROR_MESSAGE: &str = " ALTER TABLE offers ADD COLUMN error TEXT NULL; "; + +const MIGRATION_04_CREATED_INVOICES: &str = " + CREATE TABLE created_invoices ( + id INTEGER NOT NULL PRIMARY KEY, + hash INTEGER NOT NULL, + invoice TEXT NOT NULL + ); +"; + pub(crate) fn migrate(conn: &mut Connection) -> Result<()> { migrations() .to_latest(conn) @@ -64,6 +73,7 @@ fn migrations() -> Migrations<'static> { M::up(MIGRATION_01_INIT), M::up(MIGRATION_02_FUNDS_MIGRATION_STATUS), M::up(MIGRATION_03_OFFER_ERROR_MESSAGE), + M::up(MIGRATION_04_CREATED_INVOICES), ]) }