diff --git a/examples/3l-node/cli.rs b/examples/3l-node/cli.rs index d3f3f8dc..da3bfb54 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..1b5b04dd 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -1,9 +1,10 @@ use crate::errors::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::Connection; use rusqlite::Row; @@ -66,13 +67,14 @@ impl DataStore { topup_value_minor_units, exchange_fee_minor_units, exchange_fee_rate_permyriad, + error, }) = offer { let exchanged_at: DateTime = updated_at.into(); 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)\ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\ ", ( payment_hash, @@ -82,7 +84,8 @@ impl DataStore { &exchanged_at, topup_value_minor_units, exchange_fee_minor_units, - exchange_fee_rate_permyriad + exchange_fee_rate_permyriad, + from_offer_error(error) ), ) .map_to_invalid_input("Failed to add new incoming pocket offer to offers db")?; @@ -99,7 +102,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 \ 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 +265,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)?), })) } None => Ok(None), @@ -293,13 +297,77 @@ fn local_payment_data_from_row(row: &Row) -> rusqlite::Result }) } +pub fn from_offer_error(error: Option) -> Option { + error.map(|e| match e { + PocketOfferError::TemporaryFailure { code } => match code { + TemporaryFailureCode::NoRoute => "no_route".to_string(), + TemporaryFailureCode::InvoiceExpired => "invoice_expired".to_string(), + TemporaryFailureCode::Unexpected => "error".to_string(), + TemporaryFailureCode::Unknown { msg } => msg, + }, + PocketOfferError::PermanentFailure { code } => match code { + PermanentFailureCode::ThresholdExceeded => "threshold_exceeded".to_string(), + PermanentFailureCode::OrderInactive => "order_inactive".to_string(), + PermanentFailureCode::CompaniesUnsupported => "companies_unsupported".to_string(), + PermanentFailureCode::CountryUnsupported => "country_unsupported".to_string(), + PermanentFailureCode::OtherRiskDetected => "other_risk_detected".to_string(), + PermanentFailureCode::CustomerRequested => "customer_requested".to_string(), + PermanentFailureCode::AccountNotMatching => "account_not_matching".to_string(), + PermanentFailureCode::PayoutExpired => "payout_expired".to_string(), + }, + }) +} + +pub fn to_offer_error(code: Option) -> Option { + code.map(|c| match &*c { + "no_route" => PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::NoRoute, + }, + "invoice_expired" => PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::InvoiceExpired, + }, + "error" => PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::Unexpected, + }, + "threshold_exceeded" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::ThresholdExceeded, + }, + "order_inactive" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::OrderInactive, + }, + "companies_unsupported" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CompaniesUnsupported, + }, + "country_unsupported" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CountryUnsupported, + }, + "other_risk_detected" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::OtherRiskDetected, + }, + "customer_requested" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::CustomerRequested, + }, + "account_not_matching" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::AccountNotMatching, + }, + "payout_expired" => PocketOfferError::PermanentFailure { + code: PermanentFailureCode::PayoutExpired, + }, + e => PocketOfferError::TemporaryFailure { + code: TemporaryFailureCode::Unknown { msg: e.to_string() }, + }, + }) +} + #[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 +410,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 +446,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 +491,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 56d9a7b9..a6ec569d 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, }, } @@ -1104,6 +1114,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 c69e3556..de2e6f9c 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..25d29c02 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -50,6 +50,9 @@ const MIGRATION_02_FUNDS_MIGRATION_STATUS: &str = " VALUES (0); "; +const MIGRATION_03_OFFER_ERROR_MESSAGE: &str = " + ALTER TABLE offers ADD COLUMN error TEXT NULL; +"; pub(crate) fn migrate(conn: &mut Connection) -> Result<()> { migrations() .to_latest(conn) @@ -60,6 +63,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), ]) }