Skip to content

Commit

Permalink
Merge pull request #734 from getlipa/feature/persist-invoices-locally
Browse files Browse the repository at this point in the history
Persist created invoices
  • Loading branch information
danielgranhao authored Nov 8, 2023
2 parents 19ef11d + 3c9ef50 commit 792734e
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 15 deletions.
4 changes: 2 additions & 2 deletions src/amount.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<FiatValue>,
Expand Down
104 changes: 104 additions & 0 deletions src/data_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
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::<usize, String>(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<Option<String>> {
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::<usize, String>(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,
Expand Down Expand Up @@ -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}"));
Expand Down
2 changes: 1 addition & 1 deletion src/invoice_details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
134 changes: 122 additions & 12 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -835,33 +845,64 @@ 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")?
.into_iter()
.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::<Result<Vec<Payment>>>()
.collect::<Result<Vec<Payment>>>()?;

let breez_payment_invoices: HashSet<String> = 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::<Result<Vec<Payment>>>()?;

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
///
/// Parameters:
/// * `hash` - hex representation of payment hash
pub fn get_payment(&self, hash: String) -> Result<Payment> {
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(
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -971,6 +1029,58 @@ impl LightningNode {
})
}

fn payment_from_created_invoice(&self, invoice: &str) -> Result<Payment> {
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) {
Expand Down
10 changes: 10 additions & 0 deletions src/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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),
])
}

Expand Down

0 comments on commit 792734e

Please sign in to comment.