diff --git a/Cargo.lock b/Cargo.lock index 13d0307a..c790135a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -698,7 +698,7 @@ dependencies = [ [[package]] name = "chameleon" version = "0.1.0" -source = "git+https://github.com/getlipa/wild?tag=v1.12.0#abb5de0c58487955ce13d1d5446cc11fe2d9e91e" +source = "git+https://github.com/getlipa/wild/?tag=v1.13.0#0f4c10d3874fd19f27fa2c2e4c4240447fdc532a" dependencies = [ "graphql", "honey-badger", @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "crow" version = "0.1.0" -source = "git+https://github.com/getlipa/wild?tag=v1.12.0#abb5de0c58487955ce13d1d5446cc11fe2d9e91e" +source = "git+https://github.com/getlipa/wild/?tag=v1.13.0#0f4c10d3874fd19f27fa2c2e4c4240447fdc532a" dependencies = [ "graphql", "honey-badger", @@ -1540,7 +1540,7 @@ dependencies = [ [[package]] name = "graphql" version = "0.1.0" -source = "git+https://github.com/getlipa/wild?tag=v1.12.0#abb5de0c58487955ce13d1d5446cc11fe2d9e91e" +source = "git+https://github.com/getlipa/wild/?tag=v1.13.0#0f4c10d3874fd19f27fa2c2e4c4240447fdc532a" dependencies = [ "chrono", "graphql_client", @@ -1741,7 +1741,7 @@ dependencies = [ [[package]] name = "honey-badger" version = "1.0.1" -source = "git+https://github.com/getlipa/wild?tag=v1.12.0#abb5de0c58487955ce13d1d5446cc11fe2d9e91e" +source = "git+https://github.com/getlipa/wild/?tag=v1.13.0#0f4c10d3874fd19f27fa2c2e4c4240447fdc532a" dependencies = [ "base64 0.21.4", "bdk", diff --git a/Cargo.toml b/Cargo.toml index 04208d42..26593c5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,10 @@ name = "uniffi_lipalightninglib" nigiri = [] [dependencies] -chameleon = { git = "https://github.com/getlipa/wild", tag = "v1.12.0" } -crow = { git = "https://github.com/getlipa/wild", tag = "v1.12.0" } -honey-badger = { git = "https://github.com/getlipa/wild", tag = "v1.12.0" } -graphql = { git = "https://github.com/getlipa/wild", tag = "v1.12.0" } +chameleon = { git = "https://github.com/getlipa/wild/", tag = "v1.13.0" } +crow = { git = "https://github.com/getlipa/wild/", tag = "v1.13.0" } +honey-badger = { git = "https://github.com/getlipa/wild/", tag = "v1.13.0" } +graphql = { git = "https://github.com/getlipa/wild/", tag = "v1.13.0" } perro = { git = "https://github.com/getlipa/perro", tag = "v1.1.0" } breez-sdk-core = { git = "https://github.com/breez/breez-sdk", tag = "0.2.5" } diff --git a/examples/3l-node/cli.rs b/examples/3l-node/cli.rs index 956ce7d5..a3e3b868 100644 --- a/examples/3l-node/cli.rs +++ b/examples/3l-node/cli.rs @@ -607,6 +607,7 @@ fn list_offers(node: &LightningNode) -> Result<(), String> { topup_value_minor_units, exchange_fee_minor_units, exchange_fee_rate_permyriad, + error, } => { println!(" ID: {id}"); println!( @@ -628,6 +629,10 @@ fn list_offers(node: &LightningNode) -> Result<(), String> { " Exchange at: {}", exchanged_at.format("%d/%m/%Y %T UTC"), ); + + if let Some(e) = error { + println!(" Failure reason: {:?}", e); + } } } println!(" Status: {:?}", offer.status); @@ -709,6 +714,7 @@ fn offer_to_string(offer: Option) -> String { topup_value_minor_units, exchange_fee_minor_units, exchange_fee_rate_permyriad, + .. }) => { let updated_at: DateTime = updated_at.into(); format!( @@ -716,7 +722,7 @@ fn offer_to_string(offer: Option) -> String { topup_value_minor_units as f64 / 100f64, updated_at.format("%d/%m/%Y %T UTC"), exchange_fee_rate_permyriad as f64 / 100f64, - exchange_fee_minor_units as f64 / 100f64 + exchange_fee_minor_units as f64 / 100f64, ) } None => "None".to_string(), diff --git a/src/data_store.rs b/src/data_store.rs index cdc24813..48a67718 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -1,10 +1,12 @@ -use crate::errors::Result; +use crate::errors::{Error, Result}; use crate::fund_migration::MigrationStatus; use crate::migrations::migrate; -use crate::{ExchangeRate, OfferKind, TzConfig, UserPreferences}; +use crate::{ExchangeRate, OfferKind, PocketOfferError, TzConfig, UserPreferences}; use chrono::{DateTime, Utc}; +use crow::{PermanentFailureCode, TemporaryFailureCode}; use perro::MapToError; +use rusqlite::types::Type; use rusqlite::Connection; use rusqlite::Row; use std::time::SystemTime; @@ -66,13 +68,15 @@ impl DataStore { topup_value_minor_units, exchange_fee_minor_units, exchange_fee_rate_permyriad, + error, }) = offer { let exchanged_at: DateTime = updated_at.into(); + let (error_type, error_code, error_message) = from_offer_error(error); tx.execute( "\ - INSERT INTO offers (payment_hash, pocket_id, fiat_currency, rate, exchanged_at, topup_value_minor_units, exchange_fee_minor_units, exchange_fee_rate_permyriad)\ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)\ + INSERT INTO offers (payment_hash, pocket_id, fiat_currency, rate, exchanged_at, topup_value_minor_units, exchange_fee_minor_units, exchange_fee_rate_permyriad, error_type, error_code, error_message)\ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)\ ", ( payment_hash, @@ -82,7 +86,10 @@ impl DataStore { &exchanged_at, topup_value_minor_units, exchange_fee_minor_units, - exchange_fee_rate_permyriad + exchange_fee_rate_permyriad, + error_type, + error_code, + error_message ), ) .map_to_invalid_input("Failed to add new incoming pocket offer to offers db")?; @@ -99,7 +106,7 @@ impl DataStore { " \ SELECT timezone_id, timezone_utc_offset_secs, payments.fiat_currency, h.rate, h.updated_at, \ o.pocket_id, o.fiat_currency, o.rate, o.exchanged_at, o.topup_value_minor_units, \ - o.exchange_fee_minor_units, o.exchange_fee_rate_permyriad \ + o.exchange_fee_minor_units, o.exchange_fee_rate_permyriad, o.error_type, o.error_code, o.error_message \ FROM payments \ LEFT JOIN exchange_rates_history h on payments.exchange_rates_history_snaphot_id=h.snapshot_id \ AND payments.fiat_currency=h.fiat_currency \ @@ -262,6 +269,7 @@ fn offer_kind_from_row(row: &Row) -> rusqlite::Result> { topup_value_minor_units, exchange_fee_minor_units, exchange_fee_rate_permyriad, + error: to_offer_error(row.get(12)?, row.get(13)?, row.get(14)?)?, })) } None => Ok(None), @@ -293,13 +301,114 @@ fn local_payment_data_from_row(row: &Row) -> rusqlite::Result }) } +pub fn from_offer_error( + error: Option, +) -> (Option, Option, Option) { + match error { + None => (None, None, None), + Some(e) => match e { + PocketOfferError::TemporaryFailure { code } => match code { + TemporaryFailureCode::Unknown { ref msg } => ( + Some(String::from("TemporaryFailure")), + Some(String::from("Unknown")), + Some(msg.to_string()), + ), + _ => ( + Some(String::from("TemporaryFailure")), + Some(format!("{code:?}")), + None, + ), + }, + PocketOfferError::PermanentFailure { code } => ( + Some(String::from("PermanentFailure")), + Some(format!("{code:?}")), + None, + ), + }, + } +} + +pub fn to_offer_error( + error_type: Option, + error_code: Option, + error_message: Option, +) -> rusqlite::Result> { + Ok(match error_type { + None => None, + Some(error_type) => match error_type.as_str() { + "TemporaryFailure" => match error_code { + None => return Err(get_from_sql_conversion_error()), + Some(error_code) => Some(match error_code.as_str() { + "NoRoute" => PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::NoRoute, + }, + "InvoiceExpired" => PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::InvoiceExpired, + }, + "Unexpected" => PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::Unexpected, + }, + "Unknown" => match error_message { + None => return Err(get_from_sql_conversion_error()), + Some(error_message) => PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::Unknown { msg: error_message }, + }, + }, + _ => return Err(get_from_sql_conversion_error()), + }), + }, + "PermanentFailure" => match error_code { + None => return Err(get_from_sql_conversion_error()), + Some(error_code) => Some(match error_code.as_str() { + "ThresholdExceeded" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::ThresholdExceeded, + }, + "OrderInactive" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::OrderInactive, + }, + "CompaniesUnsupported" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CompaniesUnsupported, + }, + "CountryUnsupported" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CountryUnsupported, + }, + "OtherRiskDetected" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::OtherRiskDetected, + }, + "CustomerRequested" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CustomerRequested, + }, + "AccountNotMatching" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::AccountNotMatching, + }, + "PayoutExpired" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::PayoutExpired, + }, + _ => return Err(get_from_sql_conversion_error()), + }), + }, + _ => return Err(get_from_sql_conversion_error()), + }, + }) +} + +fn get_from_sql_conversion_error() -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure( + 1, + Type::Text, + Box::new(Error::PermanentFailure { msg: String::new() }), + ) +} + #[cfg(test)] mod tests { use crate::config::TzConfig; use crate::data_store::DataStore; use crate::fund_migration::MigrationStatus; - use crate::{ExchangeRate, OfferKind, UserPreferences}; + use crate::{ExchangeRate, OfferKind, PocketOfferError, UserPreferences}; + use crow::TopupError::TemporaryFailure; + use crow::{PermanentFailureCode, TemporaryFailureCode}; use std::fs; use std::thread::sleep; use std::time::{Duration, SystemTime}; @@ -342,6 +451,22 @@ mod tests { topup_value_minor_units: 51245, exchange_fee_minor_units: 123, exchange_fee_rate_permyriad: 50, + error: Some(TemporaryFailure { + code: TemporaryFailureCode::NoRoute, + }), + }; + + let offer_kind_no_error = OfferKind::Pocket { + id: "id".to_string(), + exchange_rate: ExchangeRate { + currency_code: "EUR".to_string(), + rate: 5123, + updated_at: SystemTime::now(), + }, + topup_value_minor_units: 51245, + exchange_fee_minor_units: 123, + exchange_fee_rate_permyriad: 50, + error: None, }; data_store @@ -362,11 +487,20 @@ mod tests { .store_payment_info( "hash - no offer", user_preferences.clone(), - exchange_rates, + exchange_rates.clone(), None, ) .unwrap(); + data_store + .store_payment_info( + "hash - no error", + user_preferences.clone(), + exchange_rates, + Some(offer_kind_no_error.clone()), + ) + .unwrap(); + assert!(data_store .retrieve_payment_info("non existent hash") .unwrap() @@ -398,6 +532,195 @@ mod tests { user_preferences.fiat_currency ); assert_eq!(local_payment_data.exchange_rate.rate, 4123); + + let local_payment_data = data_store + .retrieve_payment_info("hash - no error") + .unwrap() + .unwrap(); + assert_eq!(local_payment_data.offer.unwrap(), offer_kind_no_error); + } + #[test] + fn test_offer_storage() { + let db_name = String::from("offers.db3"); + reset_db(&db_name); + let mut data_store = DataStore::new(&format!("{TEST_DB_PATH}/{db_name}")).unwrap(); + + // Temporary failures + let offer_kind_no_route = build_offer_kind_with_error(PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::NoRoute, + }); + store_payment_with_offer_and_test( + offer_kind_no_route, + &mut data_store, + "offer_kind_no_route", + ); + + let offer_kind_invoice_expired = + build_offer_kind_with_error(PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::InvoiceExpired, + }); + store_payment_with_offer_and_test( + offer_kind_invoice_expired, + &mut data_store, + "offer_kind_invoice_expired", + ); + + let offer_kind_unexpected = + build_offer_kind_with_error(PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::Unexpected, + }); + store_payment_with_offer_and_test( + offer_kind_unexpected, + &mut data_store, + "offer_kind_unexpected", + ); + + let offer_kind_unknown = build_offer_kind_with_error(PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::Unknown { msg: "Test".into() }, + }); + store_payment_with_offer_and_test( + offer_kind_unknown, + &mut data_store, + "offer_kind_unknown", + ); + + // Permanent failures + let offer_kind_threshold_exceeded = + build_offer_kind_with_error(PocketOfferError::PermanentFailure { + code: PermanentFailureCode::ThresholdExceeded, + }); + store_payment_with_offer_and_test( + offer_kind_threshold_exceeded, + &mut data_store, + "offer_kind_threshold_exceeded", + ); + + let offer_kind_order_inactive = + build_offer_kind_with_error(PocketOfferError::PermanentFailure { + code: PermanentFailureCode::OrderInactive, + }); + store_payment_with_offer_and_test( + offer_kind_order_inactive.clone(), + &mut data_store, + "offer_kind_order_inactive", + ); + + let offer_kind_companies_unsupported = + build_offer_kind_with_error(PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CompaniesUnsupported, + }); + store_payment_with_offer_and_test( + offer_kind_companies_unsupported, + &mut data_store, + "offer_kind_companies_unsupported", + ); + + let offer_kind_country_unsuported = + build_offer_kind_with_error(PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CountryUnsupported, + }); + store_payment_with_offer_and_test( + offer_kind_country_unsuported, + &mut data_store, + "offer_kind_country_unsuported", + ); + + let offer_kind_other_risk_detected = + build_offer_kind_with_error(PocketOfferError::PermanentFailure { + code: PermanentFailureCode::OtherRiskDetected, + }); + store_payment_with_offer_and_test( + offer_kind_other_risk_detected, + &mut data_store, + "offer_kind_other_risk_detected", + ); + + let offer_kind_customer_requested = + build_offer_kind_with_error(PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CustomerRequested, + }); + store_payment_with_offer_and_test( + offer_kind_customer_requested, + &mut data_store, + "offer_kind_customer_requested", + ); + + let offer_kind_account_not_matching = + build_offer_kind_with_error(PocketOfferError::PermanentFailure { + code: PermanentFailureCode::AccountNotMatching, + }); + store_payment_with_offer_and_test( + offer_kind_account_not_matching, + &mut data_store, + "offer_kind_account_not_matching", + ); + + let offer_kind_payout_expired = + build_offer_kind_with_error(PocketOfferError::PermanentFailure { + code: PermanentFailureCode::PayoutExpired, + }); + store_payment_with_offer_and_test( + offer_kind_payout_expired, + &mut data_store, + "offer_kind_payout_expired", + ); + } + + fn build_offer_kind_with_error(error: PocketOfferError) -> OfferKind { + OfferKind::Pocket { + id: "id".to_string(), + exchange_rate: ExchangeRate { + currency_code: "EUR".to_string(), + rate: 5123, + updated_at: SystemTime::now(), + }, + topup_value_minor_units: 51245, + exchange_fee_minor_units: 123, + exchange_fee_rate_permyriad: 50, + error: Some(error), + } + } + + fn store_payment_with_offer_and_test(offer: OfferKind, data_store: &mut DataStore, hash: &str) { + let user_preferences = UserPreferences { + fiat_currency: "EUR".to_string(), + timezone_config: TzConfig { + timezone_id: "Bern".to_string(), + timezone_utc_offset_secs: -1234, + }, + }; + + let exchange_rates = vec![ + ExchangeRate { + currency_code: "EUR".to_string(), + rate: 123, + updated_at: SystemTime::now(), + }, + ExchangeRate { + currency_code: "USD".to_string(), + rate: 234, + updated_at: SystemTime::now(), + }, + ]; + + data_store + .store_payment_info( + hash, + user_preferences.clone(), + exchange_rates, + Some(offer.clone()), + ) + .unwrap(); + + assert_eq!( + data_store + .retrieve_payment_info(hash) + .unwrap() + .unwrap() + .offer + .unwrap(), + offer + ); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 47e0b16f..9c2f1aff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,6 +51,7 @@ pub use crate::recovery::recover_lightning_node; use crate::secret::Secret; use crate::task_manager::{TaskManager, TaskPeriods}; use crate::util::unix_timestamp_to_system_time; +pub use crow::{PermanentFailureCode, TemporaryFailureCode}; use bip39::{Language, Mnemonic}; use bitcoin::hashes::hex::ToHex; @@ -63,7 +64,7 @@ use breez_sdk_core::{ OpeningFeeParams, PaymentDetails, PaymentStatus, PaymentTypeFilter, SweepRequest, }; use cipher::generic_array::typenum::U32; -use crow::{CountryCode, LanguageCode, OfferManager, TopupInfo, TopupStatus}; +use crow::{CountryCode, LanguageCode, OfferManager, TopupError, TopupInfo, TopupStatus}; use data_store::DataStore; use email_address::EmailAddress; use honey_badger::secrets::{generate_keypair, KeyPair}; @@ -206,6 +207,13 @@ pub enum OfferStatus { SETTLED, } +/// An error associated with a specific PocketOffer. Can be temporary, indicating there was an issue +/// with a previous withdrawal attempt and it can be retried, or it can be permanent. +/// +/// More information on each specific error can be found on +/// [Pocket's Documentation Page](). +pub type PocketOfferError = TopupError; + #[derive(PartialEq, Eq, Debug, Clone)] pub enum OfferKind { /// An offer related to a topup using the Pocket exchange @@ -221,6 +229,8 @@ pub enum OfferKind { exchange_fee_minor_units: u64, /// The rate of the fee expressed in permyriad (e.g. 1.5% would be 150) exchange_fee_rate_permyriad: u16, + /// The optional error that might have occurred in the offer withdrawal process + error: Option, }, } @@ -1097,6 +1107,7 @@ fn to_offer(topup_info: TopupInfo, current_rate: &Option) -> Offer topup_value_minor_units: topup_info.topup_value_minor_units, exchange_fee_minor_units: topup_info.exchange_fee_minor_units, exchange_fee_rate_permyriad: topup_info.exchange_fee_rate_permyriad, + error: topup_info.error, }, amount: (topup_info.amount_sat * 1000).to_amount_down(current_rate), lnurlw: topup_info.lnurlw, diff --git a/src/lipalightninglib.udl b/src/lipalightninglib.udl index 05000701..c7564dd9 100644 --- a/src/lipalightninglib.udl +++ b/src/lipalightninglib.udl @@ -265,6 +265,37 @@ dictionary OfferInfo { OfferStatus status; }; +enum PermanentFailureCode { + "ThresholdExceeded", + "OrderInactive", + "CompaniesUnsupported", + "CountryUnsupported", + "OtherRiskDetected", + "CustomerRequested", + "AccountNotMatching", + "PayoutExpired", +}; + +[Enum] +interface TemporaryFailureCode { + NoRoute(); + InvoiceExpired(); + Unexpected(); + Unknown( + string msg + ); +}; + +[Enum] +interface PocketOfferError { + TemporaryFailure( + TemporaryFailureCode code + ); + PermanentFailure( + PermanentFailureCode code + ); +}; + [Enum] interface OfferKind { Pocket( @@ -272,7 +303,8 @@ interface OfferKind { ExchangeRate exchange_rate, u64 topup_value_minor_units, u64 exchange_fee_minor_units, - u16 exchange_fee_rate_permyriad + u16 exchange_fee_rate_permyriad, + PocketOfferError? error ); }; diff --git a/src/migrations.rs b/src/migrations.rs index ea2a8e57..67037e2e 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -50,6 +50,11 @@ const MIGRATION_02_FUNDS_MIGRATION_STATUS: &str = " VALUES (0); "; +const MIGRATION_03_OFFER_ERROR_MESSAGE: &str = " + ALTER TABLE offers ADD COLUMN error_type TEXT NULL; + ALTER TABLE offers ADD COLUMN error_code TEXT NULL; + ALTER TABLE offers ADD COLUMN error_message TEXT NULL; +"; pub(crate) fn migrate(conn: &mut Connection) -> Result<()> { migrations() .to_latest(conn) @@ -60,6 +65,7 @@ fn migrations() -> Migrations<'static> { Migrations::new(vec![ M::up(MIGRATION_01_INIT), M::up(MIGRATION_02_FUNDS_MIGRATION_STATUS), + M::up(MIGRATION_03_OFFER_ERROR_MESSAGE), ]) }