#![allow(clippy::let_unit_value)]
extern crate core;
mod activity;
mod amount;
mod analytics;
mod async_runtime;
mod auth;
mod backup;
mod callbacks;
mod config;
mod data_store;
mod errors;
mod event;
mod exchange_rate_provider;
mod invoice_details;
mod key_derivation;
mod limits;
mod lnurl;
mod locker;
mod logger;
mod migrations;
mod notification_handling;
mod offer;
mod payment;
mod phone_number;
mod random;
mod recovery;
mod reverse_swap;
mod sanitize_input;
mod secret;
mod swap;
mod symmetric_encryption;
mod task_manager;
mod util;
pub use crate::activity::{Activity, ChannelCloseInfo, ChannelCloseState, ListActivitiesResponse};
pub use crate::amount::{Amount, FiatValue};
use crate::amount::{AsSats, Msats, Permyriad, Sats, ToAmount};
use crate::analytics::{derive_analytics_keys, AnalyticsInterceptor};
pub use crate::analytics::{AnalyticsConfig, InvoiceCreationMetadata, PaymentMetadata};
use crate::async_runtime::AsyncRuntime;
use crate::auth::{build_async_auth, build_auth};
use crate::backup::BackupManager;
pub use crate::callbacks::EventsCallback;
use crate::config::WithTimezone;
pub use crate::config::{
BreezSdkConfig, Config, MaxRoutingFeeConfig, ReceiveLimitsConfig, RemoteServicesConfig,
TzConfig, TzTime,
};
use crate::data_store::CreatedInvoice;
use crate::errors::{
map_lnurl_pay_error, map_lnurl_withdraw_error, map_send_payment_error, LnUrlWithdrawError,
LnUrlWithdrawErrorCode, LnUrlWithdrawResult,
};
pub use crate::errors::{
DecodeDataError, Error as LnError, LnUrlPayError, LnUrlPayErrorCode, LnUrlPayResult,
MnemonicError, NotificationHandlingError, NotificationHandlingErrorCode, ParseError,
ParsePhoneNumberError, ParsePhoneNumberPrefixError, PayError, PayErrorCode, PayResult, Result,
RuntimeErrorCode, SimpleError, UnsupportedDataType,
};
use crate::event::LipaEventListener;
pub use crate::exchange_rate_provider::ExchangeRate;
use crate::exchange_rate_provider::ExchangeRateProviderImpl;
pub use crate::invoice_details::InvoiceDetails;
use crate::key_derivation::derive_persistence_encryption_key;
pub use crate::limits::{LiquidityLimit, PaymentAmountLimits};
pub use crate::lnurl::{LnUrlPayDetails, LnUrlWithdrawDetails};
use crate::locker::Locker;
pub use crate::notification_handling::{handle_notification, Notification, NotificationToggles};
pub use crate::offer::{OfferInfo, OfferKind, OfferStatus};
pub use crate::payment::{
IncomingPaymentInfo, OutgoingPaymentInfo, PaymentInfo, PaymentState, Recipient,
};
pub use crate::phone_number::PhoneNumber;
use crate::phone_number::{lightning_address_to_phone_number, PhoneNumberPrefixParser};
pub use crate::recovery::recover_lightning_node;
pub use crate::reverse_swap::ReverseSwapInfo;
pub use crate::secret::{generate_secret, mnemonic_to_secret, words_by_prefix, Secret};
pub use crate::swap::{
FailedSwapInfo, ResolveFailedSwapInfo, SwapAddressInfo, SwapInfo, SwapToLightningFees,
};
use crate::symmetric_encryption::{deterministic_encrypt, encrypt};
use crate::task_manager::TaskManager;
use crate::util::{
replace_byte_arrays_by_hex_string, unix_timestamp_to_system_time, LogIgnoreError,
};
#[cfg(not(feature = "mock-deps"))]
#[allow(clippy::single_component_path_imports)]
use pocketclient;
#[cfg(feature = "mock-deps")]
use pocketclient_mock as pocketclient;
pub use crate::pocketclient::FiatTopupInfo;
use crate::pocketclient::PocketClient;
pub use breez_sdk_core::error::ReceiveOnchainError as SwapError;
pub use breez_sdk_core::error::RedeemOnchainError as SweepError;
use breez_sdk_core::error::{ReceiveOnchainError, RedeemOnchainError, SendPaymentError};
pub use breez_sdk_core::HealthCheckStatus as BreezHealthCheckStatus;
pub use breez_sdk_core::ReverseSwapStatus;
use breez_sdk_core::{
parse, parse_invoice, BitcoinAddressData, BreezServices, ClosedChannelPaymentDetails,
ConnectRequest, EnvironmentType, EventListener, GreenlightCredentials, GreenlightNodeConfig,
InputType, ListPaymentsRequest, LnUrlPayRequest, LnUrlPayRequestData, LnUrlWithdrawRequest,
LnUrlWithdrawRequestData, Network, NodeConfig, OpenChannelFeeRequest, OpeningFeeParams,
PayOnchainRequest, PaymentDetails, PaymentStatus, PaymentTypeFilter,
PrepareOnchainPaymentRequest, PrepareOnchainPaymentResponse, PrepareRedeemOnchainFundsRequest,
PrepareRefundRequest, ReceiveOnchainRequest, RedeemOnchainFundsRequest, RefundRequest,
ReportIssueRequest, ReportPaymentFailureDetails, SendPaymentRequest, SignMessageRequest,
UnspentTransactionOutput,
};
use crow::{CountryCode, LanguageCode, OfferManager, TopupError, TopupInfo};
pub use crow::{PermanentFailureCode, TemporaryFailureCode};
use data_store::DataStore;
use email_address::EmailAddress;
use hex::FromHex;
use honeybadger::Auth;
pub use honeybadger::{TermsAndConditions, TermsAndConditionsStatus};
use iban::Iban;
use log::{debug, error, info, warn, Level};
use logger::init_logger_once;
use num_enum::TryFromPrimitive;
use parrot::AnalyticsClient;
pub use parrot::PaymentSource;
use perro::{
ensure, invalid_input, permanent_failure, runtime_error, MapToError, OptionToError, ResultTrait,
};
use squirrel::RemoteBackupClient;
use std::cmp::{min, Reverse};
use std::collections::HashSet;
use std::ops::Not;
use std::path::Path;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use std::{env, fs};
use uuid::Uuid;
const LOGS_DIR: &str = "logs";
const CLN_DUST_LIMIT_SAT: u64 = 546;
pub(crate) const DB_FILENAME: &str = "db2.db3";
pub enum RangeHit {
Below { min: Amount },
In,
Above { max: Amount },
}
pub struct LspFee {
pub channel_minimum_fee: Amount,
pub channel_fee_permyriad: u64,
}
pub struct CalculateLspFeeResponse {
pub lsp_fee: Amount,
pub lsp_fee_params: Option<OpeningFeeParams>,
}
pub struct NodeInfo {
pub node_pubkey: String,
pub peers: Vec<String>,
pub onchain_balance: Amount,
pub channels_info: ChannelsInfo,
}
pub struct ChannelsInfo {
pub local_balance: Amount,
pub max_receivable_single_payment: Amount,
pub total_inbound_capacity: Amount,
pub outbound_capacity: Amount,
}
pub enum MaxRoutingFeeMode {
Relative {
max_fee_permyriad: u16,
},
Absolute {
max_fee_amount: Amount,
},
}
pub type PocketOfferError = TopupError;
#[derive(Clone)]
pub struct SweepInfo {
pub address: String,
pub onchain_fee_rate: u32,
pub onchain_fee_amount: Amount,
pub amount: Amount,
}
#[derive(Clone, PartialEq, Debug)]
pub(crate) struct UserPreferences {
fiat_currency: String,
timezone_config: TzConfig,
}
pub enum DecodedData {
Bolt11Invoice {
invoice_details: InvoiceDetails,
},
LnUrlPay {
lnurl_pay_details: LnUrlPayDetails,
},
LnUrlWithdraw {
lnurl_withdraw_details: LnUrlWithdrawDetails,
},
OnchainAddress {
onchain_address_details: BitcoinAddressData,
},
}
#[derive(Debug)]
pub enum InvoiceAffordability {
NotEnoughFunds,
UnaffordableFees,
Affordable,
}
pub struct ClearWalletInfo {
pub clear_amount: Amount,
pub total_estimated_fees: Amount,
pub onchain_fee: Amount,
pub swap_fee: Amount,
prepare_response: PrepareOnchainPaymentResponse,
}
#[derive(PartialEq, Eq, Debug, TryFromPrimitive, Clone, Copy)]
#[repr(u8)]
pub(crate) enum EnableStatus {
Enabled,
FeatureDisabled,
}
pub enum FeatureFlag {
LightningAddress,
PhoneNumber,
}
pub struct LightningNode {
user_preferences: Arc<Mutex<UserPreferences>>,
sdk: Arc<BreezServices>,
auth: Arc<Auth>,
async_auth: Arc<honeybadger::asynchronous::Auth>,
fiat_topup_client: PocketClient,
offer_manager: OfferManager,
rt: AsyncRuntime,
data_store: Arc<Mutex<DataStore>>,
task_manager: Arc<Mutex<TaskManager>>,
analytics_interceptor: Arc<AnalyticsInterceptor>,
allowed_countries_country_iso_3166_1_alpha_2: Vec<String>,
phone_number_prefix_parser: PhoneNumberPrefixParser,
persistence_encryption_key: [u8; 32],
config: Config,
}
pub struct OnchainResolvingFees {
pub swap_fees: Option<SwapToLightningFees>,
pub sweep_onchain_fee_estimate: Amount,
pub sat_per_vbyte: u32,
}
#[allow(clippy::large_enum_variant)]
pub enum ActionRequiredItem {
UncompletedOffer { offer: OfferInfo },
UnresolvedFailedSwap { failed_swap: FailedSwapInfo },
ChannelClosesFundsAvailable { available_funds: Amount },
}
impl From<OfferInfo> for ActionRequiredItem {
fn from(value: OfferInfo) -> Self {
ActionRequiredItem::UncompletedOffer { offer: value }
}
}
impl From<FailedSwapInfo> for ActionRequiredItem {
fn from(value: FailedSwapInfo) -> Self {
ActionRequiredItem::UnresolvedFailedSwap { failed_swap: value }
}
}
impl LightningNode {
pub fn new(config: Config, events_callback: Box<dyn EventsCallback>) -> Result<Self> {
enable_backtrace();
fs::create_dir_all(&config.local_persistence_path).map_to_permanent_failure(format!(
"Failed to create directory: {}",
&config.local_persistence_path,
))?;
if let Some(level) = config.file_logging_level {
init_logger_once(
level,
&Path::new(&config.local_persistence_path).join(LOGS_DIR),
)?;
}
info!("3L version: {}", env!("GITHUB_REF"));
let rt = AsyncRuntime::new()?;
let strong_typed_seed = sanitize_input::strong_type_seed(&config.seed)?;
let auth = Arc::new(build_auth(
&strong_typed_seed,
&config.remote_services_config.backend_url,
)?);
let async_auth = Arc::new(build_async_auth(
&strong_typed_seed,
&config.remote_services_config.backend_url,
)?);
let user_preferences = Arc::new(Mutex::new(UserPreferences {
fiat_currency: config.fiat_currency.clone(),
timezone_config: config.timezone_config.clone(),
}));
let analytics_client = AnalyticsClient::new(
config.remote_services_config.backend_url.clone(),
derive_analytics_keys(&strong_typed_seed)?,
Arc::clone(&async_auth),
);
let db_path = format!("{}/{DB_FILENAME}", config.local_persistence_path);
let data_store = Arc::new(Mutex::new(DataStore::new(&db_path)?));
let analytics_config = data_store.lock_unwrap().retrieve_analytics_config()?;
let analytics_interceptor = Arc::new(AnalyticsInterceptor::new(
analytics_client,
Arc::clone(&user_preferences),
rt.handle(),
analytics_config,
));
let events_callback = Arc::new(events_callback);
let event_listener = Box::new(LipaEventListener::new(
Arc::clone(&events_callback),
Arc::clone(&analytics_interceptor),
));
let sdk = rt.handle().block_on(async {
let sdk = start_sdk(&config, event_listener).await?;
if sdk
.lsp_id()
.await
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get current lsp id",
)?
.is_none()
{
let lsps = sdk.list_lsps().await.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to list lsps",
)?;
let lsp = lsps
.into_iter()
.next()
.ok_or_runtime_error(RuntimeErrorCode::NodeUnavailable, "No lsp available")?;
sdk.connect_lsp(lsp.id).await.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to connect to lsp",
)?;
}
Ok(sdk)
})?;
let exchange_rate_provider = Box::new(ExchangeRateProviderImpl::new(
config.remote_services_config.backend_url.clone(),
Arc::clone(&auth),
));
let offer_manager = OfferManager::new(
config.remote_services_config.backend_url.clone(),
Arc::clone(&auth),
);
let fiat_topup_client = PocketClient::new(config.remote_services_config.pocket_url.clone())
.map_to_runtime_error(
RuntimeErrorCode::OfferServiceUnavailable,
"Couldn't create a fiat topup client",
)?;
let persistence_encryption_key = derive_persistence_encryption_key(&strong_typed_seed)?;
let backup_client = RemoteBackupClient::new(
config.remote_services_config.backend_url.clone(),
Arc::clone(&async_auth),
);
let backup_manager = BackupManager::new(backup_client, db_path, persistence_encryption_key);
let task_manager = Arc::new(Mutex::new(TaskManager::new(
rt.handle(),
exchange_rate_provider,
Arc::clone(&data_store),
Arc::clone(&sdk),
backup_manager,
events_callback,
config.breez_sdk_config.breez_sdk_api_key.clone(),
)?));
task_manager.lock_unwrap().foreground();
register_webhook_url(&rt, &sdk, &auth, &config)?;
let phone_number_prefix_parser =
PhoneNumberPrefixParser::new(&config.phone_number_allowed_countries_iso_3166_1_alpha_2);
Ok(LightningNode {
user_preferences,
sdk,
auth,
async_auth,
fiat_topup_client,
offer_manager,
rt,
data_store,
task_manager,
analytics_interceptor,
allowed_countries_country_iso_3166_1_alpha_2: config
.phone_number_allowed_countries_iso_3166_1_alpha_2
.clone(),
phone_number_prefix_parser,
persistence_encryption_key,
config,
})
}
pub fn get_node_info(&self) -> Result<NodeInfo> {
let node_state = self.sdk.node_info().map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to read node info",
)?;
let rate = self.get_exchange_rate();
Ok(NodeInfo {
node_pubkey: node_state.id,
peers: node_state.connected_peers,
onchain_balance: node_state
.onchain_balance_msat
.as_msats()
.to_amount_down(&rate),
channels_info: ChannelsInfo {
local_balance: node_state
.channels_balance_msat
.as_msats()
.to_amount_down(&rate),
max_receivable_single_payment: node_state
.max_receivable_single_payment_amount_msat
.as_msats()
.to_amount_down(&rate),
total_inbound_capacity: node_state
.total_inbound_liquidity_msats
.as_msats()
.to_amount_down(&rate),
outbound_capacity: node_state.max_payable_msat.as_msats().to_amount_down(&rate),
},
})
}
pub fn query_lsp_fee(&self) -> Result<LspFee> {
let exchange_rate = self.get_exchange_rate();
let lsp_fee = self.task_manager.lock_unwrap().get_lsp_fee()?;
Ok(LspFee {
channel_minimum_fee: lsp_fee.min_msat.as_msats().to_amount_up(&exchange_rate),
channel_fee_permyriad: lsp_fee.proportional as u64 / 100,
})
}
pub fn calculate_lsp_fee(&self, amount_sat: u64) -> Result<CalculateLspFeeResponse> {
let req = OpenChannelFeeRequest {
amount_msat: Some(amount_sat.as_sats().msats),
expiry: None,
};
let res = self
.rt
.handle()
.block_on(self.sdk.open_channel_fee(req))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to compute opening channel fee",
)?;
Ok(CalculateLspFeeResponse {
lsp_fee: res
.fee_msat
.ok_or_permanent_failure("Breez SDK open_channel_fee returned None lsp fee when provided with Some(amount_msat)")?
.as_msats()
.to_amount_up(&self.get_exchange_rate()),
lsp_fee_params: Some(res.fee_params),
})
}
pub fn get_payment_amount_limits(&self) -> Result<PaymentAmountLimits> {
let lsp_min_fee_amount = self.query_lsp_fee()?.channel_minimum_fee;
let max_inbound_amount = self.get_node_info()?.channels_info.total_inbound_capacity;
Ok(PaymentAmountLimits::calculate(
max_inbound_amount.sats,
lsp_min_fee_amount.sats,
&self.get_exchange_rate(),
&self.config.receive_limits_config,
))
}
pub fn create_invoice(
&self,
amount_sat: u64,
lsp_fee_params: Option<OpeningFeeParams>,
description: String,
metadata: InvoiceCreationMetadata,
) -> Result<InvoiceDetails> {
let response = self
.rt
.handle()
.block_on(
self.sdk
.receive_payment(breez_sdk_core::ReceivePaymentRequest {
amount_msat: amount_sat.as_sats().msats,
description,
preimage: None,
opening_fee_params: lsp_fee_params,
use_description_hash: None,
expiry: None,
cltv: None,
}),
)
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to create an invoice",
)?;
self.store_payment_info(&response.ln_invoice.payment_hash, None);
self.data_store
.lock_unwrap()
.store_created_invoice(
&response.ln_invoice.payment_hash,
&response.ln_invoice.bolt11,
&response.opening_fee_msat,
response.ln_invoice.timestamp + response.ln_invoice.expiry,
)
.map_to_permanent_failure("Failed to persist created invoice")?;
self.analytics_interceptor.request_initiated(
response.clone(),
self.get_exchange_rate(),
metadata,
);
Ok(InvoiceDetails::from_ln_invoice(
response.ln_invoice,
&self.get_exchange_rate(),
))
}
pub fn parse_phone_number_prefix(
&self,
phone_number_prefix: String,
) -> std::result::Result<(), ParsePhoneNumberPrefixError> {
self.phone_number_prefix_parser.parse(&phone_number_prefix)
}
pub fn parse_phone_number_to_lightning_address(
&self,
phone_number: String,
) -> std::result::Result<String, ParsePhoneNumberError> {
let phone_number = self.parse_phone_number(phone_number)?;
Ok(phone_number
.to_lightning_address(&self.config.remote_services_config.lipa_lightning_domain))
}
fn parse_phone_number(
&self,
phone_number: String,
) -> std::result::Result<PhoneNumber, ParsePhoneNumberError> {
let phone_number = PhoneNumber::parse(&phone_number)?;
ensure!(
self.allowed_countries_country_iso_3166_1_alpha_2
.contains(&phone_number.country_code.as_ref().to_string()),
ParsePhoneNumberError::UnsupportedCountry
);
Ok(phone_number)
}
pub fn decode_data(&self, data: String) -> std::result::Result<DecodedData, DecodeDataError> {
match self.rt.handle().block_on(parse(&data)) {
Ok(InputType::Bolt11 { invoice }) => {
ensure!(
invoice.network == Network::Bitcoin,
DecodeDataError::Unsupported {
typ: UnsupportedDataType::Network {
network: invoice.network.to_string(),
},
}
);
Ok(DecodedData::Bolt11Invoice {
invoice_details: InvoiceDetails::from_ln_invoice(
invoice,
&self.get_exchange_rate(),
),
})
}
Ok(InputType::LnUrlPay { data }) => Ok(DecodedData::LnUrlPay {
lnurl_pay_details: LnUrlPayDetails::from_lnurl_pay_request_data(
data,
&self.get_exchange_rate(),
)?,
}),
Ok(InputType::BitcoinAddress { address }) => Ok(DecodedData::OnchainAddress {
onchain_address_details: address,
}),
Ok(InputType::LnUrlAuth { .. }) => Err(DecodeDataError::Unsupported {
typ: UnsupportedDataType::LnUrlAuth,
}),
Ok(InputType::LnUrlError { data }) => {
Err(DecodeDataError::LnUrlError { msg: data.reason })
}
Ok(InputType::LnUrlWithdraw { data }) => Ok(DecodedData::LnUrlWithdraw {
lnurl_withdraw_details: LnUrlWithdrawDetails::from_lnurl_withdraw_request_data(
data,
&self.get_exchange_rate(),
),
}),
Ok(InputType::NodeId { .. }) => Err(DecodeDataError::Unsupported {
typ: UnsupportedDataType::NodeId,
}),
Ok(InputType::Url { .. }) => Err(DecodeDataError::Unsupported {
typ: UnsupportedDataType::Url,
}),
Err(e) => Err(DecodeDataError::Unrecognized { msg: e.to_string() }),
}
}
pub fn get_payment_max_routing_fee_mode(&self, amount_sat: u64) -> MaxRoutingFeeMode {
get_payment_max_routing_fee_mode(
&self.config.max_routing_fee_config,
amount_sat,
&self.get_exchange_rate(),
)
}
pub fn get_invoice_affordability(&self, amount_sat: u64) -> Result<InvoiceAffordability> {
let amount = amount_sat.as_sats();
let routing_fee_mode = self.get_payment_max_routing_fee_mode(amount_sat);
let max_fee_msats = match routing_fee_mode {
MaxRoutingFeeMode::Relative { max_fee_permyriad } => {
Permyriad(max_fee_permyriad).of(&amount).msats
}
MaxRoutingFeeMode::Absolute { max_fee_amount } => max_fee_amount.sats.as_sats().msats,
};
let node_state = self.sdk.node_info().map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to read node info",
)?;
if amount.msats > node_state.max_payable_msat {
return Ok(InvoiceAffordability::NotEnoughFunds);
}
if amount.msats + max_fee_msats > node_state.max_payable_msat {
return Ok(InvoiceAffordability::UnaffordableFees);
}
Ok(InvoiceAffordability::Affordable)
}
pub fn pay_invoice(
&self,
invoice_details: InvoiceDetails,
metadata: PaymentMetadata,
) -> PayResult<()> {
self.pay_open_invoice(invoice_details, 0, metadata)
}
pub fn pay_open_invoice(
&self,
invoice_details: InvoiceDetails,
amount_sat: u64,
metadata: PaymentMetadata,
) -> PayResult<()> {
let amount_msat = if amount_sat == 0 {
None
} else {
Some(amount_sat.as_sats().msats)
};
self.store_payment_info(&invoice_details.payment_hash, None);
let node_state = self
.sdk
.node_info()
.map_to_runtime_error(PayErrorCode::NodeUnavailable, "Failed to read node info")?;
ensure!(
node_state.id != invoice_details.payee_pub_key,
runtime_error(
PayErrorCode::PayingToSelf,
"A locally issued invoice tried to be paid"
)
);
self.analytics_interceptor.pay_initiated(
invoice_details.clone(),
metadata,
amount_msat,
self.get_exchange_rate(),
);
let result = self
.rt
.handle()
.block_on(self.sdk.send_payment(SendPaymentRequest {
bolt11: invoice_details.invoice,
use_trampoline: true,
amount_msat,
label: None,
}));
if matches!(
result,
Err(SendPaymentError::Generic { .. }
| SendPaymentError::PaymentFailed { .. }
| SendPaymentError::PaymentTimeout { .. }
| SendPaymentError::RouteNotFound { .. }
| SendPaymentError::RouteTooExpensive { .. }
| SendPaymentError::ServiceConnectivity { .. })
) {
self.report_send_payment_issue(invoice_details.payment_hash);
}
result.map_err(map_send_payment_error)?;
Ok(())
}
pub fn pay_lnurlp(
&self,
lnurl_pay_request_data: LnUrlPayRequestData,
amount_sat: u64,
comment: Option<String>,
) -> LnUrlPayResult<String> {
let comment_allowed = lnurl_pay_request_data.comment_allowed;
ensure!(
!matches!(comment, Some(ref comment) if comment.len() > comment_allowed as usize),
invalid_input(format!(
"The provided comment is longer than the allowed {comment_allowed} characters"
))
);
let payment_hash = match self
.rt
.handle()
.block_on(self.sdk.lnurl_pay(LnUrlPayRequest {
data: lnurl_pay_request_data,
amount_msat: amount_sat.as_sats().msats,
use_trampoline: true,
comment,
payment_label: None,
validate_success_action_url: Some(false),
}))
.map_err(map_lnurl_pay_error)?
{
breez_sdk_core::lnurl::pay::LnUrlPayResult::EndpointSuccess { data } => {
Ok(data.payment.id)
}
breez_sdk_core::lnurl::pay::LnUrlPayResult::EndpointError { data } => runtime_error!(
LnUrlPayErrorCode::LnUrlServerError,
"LNURL server returned error: {}",
data.reason
),
breez_sdk_core::lnurl::pay::LnUrlPayResult::PayError { data } => {
self.report_send_payment_issue(data.payment_hash);
runtime_error!(
LnUrlPayErrorCode::PaymentFailed,
"Paying invoice for LNURL pay failed: {}",
data.reason
)
}
}?;
self.store_payment_info(&payment_hash, None);
Ok(payment_hash)
}
pub fn list_recipients(&self) -> Result<Vec<Recipient>> {
let list_payments_request = ListPaymentsRequest {
filters: Some(vec![PaymentTypeFilter::Sent]),
metadata_filters: None,
from_timestamp: None,
to_timestamp: None,
include_failures: Some(true),
limit: None,
offset: None,
};
let to_lightning_address = |p: breez_sdk_core::Payment| match p.details {
PaymentDetails::Ln { data } => match data.ln_address {
Some(lightning_address) => Some((lightning_address, -p.payment_time)),
None => None,
},
_ => None,
};
let mut lightning_addresses = self
.rt
.handle()
.block_on(self.sdk.list_payments(list_payments_request))
.map_to_runtime_error(RuntimeErrorCode::NodeUnavailable, "Failed to list payments")?
.into_iter()
.flat_map(to_lightning_address)
.collect::<Vec<_>>();
lightning_addresses.sort();
lightning_addresses.dedup_by_key(|p| p.0.clone());
lightning_addresses.sort_by_key(|p| p.1);
let recipients = lightning_addresses
.into_iter()
.map(|p| {
Recipient::from_lightning_address(
&p.0,
&self.config.remote_services_config.lipa_lightning_domain,
)
})
.collect();
Ok(recipients)
}
pub fn withdraw_lnurlw(
&self,
lnurl_withdraw_request_data: LnUrlWithdrawRequestData,
amount_sat: u64,
) -> LnUrlWithdrawResult<String> {
let payment_hash = match self
.rt
.handle()
.block_on(self.sdk.lnurl_withdraw(LnUrlWithdrawRequest {
data: lnurl_withdraw_request_data,
amount_msat: amount_sat.as_sats().msats,
description: None,
}))
.map_err(map_lnurl_withdraw_error)?
{
breez_sdk_core::LnUrlWithdrawResult::Ok { data } => Ok(data.invoice.payment_hash),
breez_sdk_core::LnUrlWithdrawResult::Timeout { data } => {
warn!("Tolerating timeout on submitting invoice to LNURL-w");
Ok(data.invoice.payment_hash)
}
breez_sdk_core::LnUrlWithdrawResult::ErrorStatus { data } => runtime_error!(
LnUrlWithdrawErrorCode::LnUrlServerError,
"LNURL server returned error: {}",
data.reason
),
}?;
self.store_payment_info(&payment_hash, None);
Ok(payment_hash)
}
pub fn get_latest_activities(
&self,
number_of_completed_activities: u32,
) -> Result<ListActivitiesResponse> {
const LEEWAY_FOR_PENDING_PAYMENTS: u32 = 30;
let list_payments_request = ListPaymentsRequest {
filters: Some(vec![
PaymentTypeFilter::Sent,
PaymentTypeFilter::Received,
PaymentTypeFilter::ClosedChannel,
]),
metadata_filters: None,
from_timestamp: None,
to_timestamp: None,
include_failures: Some(true),
limit: Some(number_of_completed_activities + LEEWAY_FOR_PENDING_PAYMENTS),
offset: None,
};
let breez_activities = self
.rt
.handle()
.block_on(self.sdk.list_payments(list_payments_request))
.map_to_runtime_error(RuntimeErrorCode::NodeUnavailable, "Failed to list payments")?
.into_iter()
.map(|p| self.activity_from_breez_payment(p))
.filter_map(filter_out_and_log_corrupted_activities)
.collect::<Vec<_>>();
let created_invoices = self
.data_store
.lock_unwrap()
.retrieve_created_invoices(number_of_completed_activities)?;
let number_of_created_invoices = created_invoices.len();
let mut activities = self.multiplex_activities(breez_activities, created_invoices);
activities.sort_by_cached_key(|m| Reverse(m.get_time()));
let look_for_pending = LEEWAY_FOR_PENDING_PAYMENTS as usize + number_of_created_invoices;
let mut tail_activities = activities.split_off(min(look_for_pending, activities.len()));
let head_activities = activities;
let (mut pending_activities, mut completed_activities): (Vec<_>, Vec<_>) =
head_activities.into_iter().partition(Activity::is_pending);
tail_activities.retain(|m| !m.is_pending());
completed_activities.append(&mut tail_activities);
completed_activities.truncate(number_of_completed_activities as usize);
if let Some(in_progress_swap) = self
.rt
.handle()
.block_on(self.sdk.in_progress_swap())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get in-progress swap",
)?
{
let created_at = unix_timestamp_to_system_time(in_progress_swap.created_at as u64)
.with_timezone(self.user_preferences.lock_unwrap().clone().timezone_config);
pending_activities.push(Activity::Swap {
incoming_payment_info: None,
swap_info: SwapInfo {
bitcoin_address: in_progress_swap.bitcoin_address,
created_at,
paid_amount: (in_progress_swap.unconfirmed_sats
+ in_progress_swap.confirmed_sats)
.as_sats()
.to_amount_down(&self.get_exchange_rate()),
txid: in_progress_swap
.unconfirmed_tx_ids
.first()
.or(in_progress_swap.confirmed_tx_ids.first())
.ok_or(permanent_failure("In-progress swap doesn't have any txids"))?
.clone(),
},
})
}
pending_activities.sort_by_cached_key(|m| Reverse(m.get_time()));
Ok(ListActivitiesResponse {
pending_activities,
completed_activities,
})
}
fn multiplex_activities(
&self,
breez_activities: Vec<Activity>,
local_created_invoices: Vec<CreatedInvoice>,
) -> Vec<Activity> {
let breez_payment_hashes: HashSet<_> = breez_activities
.iter()
.filter_map(|m| m.get_payment_info().map(|p| p.hash.clone()))
.collect();
let mut activities = local_created_invoices
.into_iter()
.filter(|i| !breez_payment_hashes.contains(i.hash.as_str()))
.map(|i| self.payment_from_created_invoice(&i))
.filter_map(filter_out_and_log_corrupted_payments)
.map(|p| Activity::IncomingPayment {
incoming_payment_info: p,
})
.collect::<Vec<_>>();
activities.extend(breez_activities);
activities
}
pub fn get_incoming_payment(&self, hash: String) -> Result<IncomingPaymentInfo> {
if let Some(breez_payment) = self
.rt
.handle()
.block_on(self.sdk.payment_by_hash(hash.clone()))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get payment by hash",
)?
{
return match self.activity_from_breez_ln_payment(breez_payment)? {
Activity::IncomingPayment {
incoming_payment_info,
} => Ok(incoming_payment_info),
Activity::OutgoingPayment { .. } => invalid_input!("OutgoingPayment was found"),
Activity::OfferClaim {
incoming_payment_info,
..
} => Ok(incoming_payment_info),
Activity::Swap {
incoming_payment_info: Some(incoming_payment_info),
..
} => Ok(incoming_payment_info),
Activity::Swap {
incoming_payment_info: None,
..
} => invalid_input!("Pending swap was found"),
Activity::ReverseSwap { .. } => invalid_input!("ReverseSwap was found"),
Activity::ChannelClose { .. } => invalid_input!("ChannelClose was found"),
};
}
let invoice = self
.data_store
.lock_unwrap()
.retrieve_created_invoice_by_hash(&hash)?
.ok_or_invalid_input("No payment with provided hash was found")?;
self.payment_from_created_invoice(&invoice)
}
pub fn get_outgoing_payment(&self, hash: String) -> Result<OutgoingPaymentInfo> {
let breez_payment = self
.rt
.handle()
.block_on(self.sdk.payment_by_hash(hash))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get payment by hash",
)?
.ok_or_invalid_input("No payment with provided hash was found")?;
match self.activity_from_breez_ln_payment(breez_payment)? {
Activity::IncomingPayment { .. } => invalid_input!("IncomingPayment was found"),
Activity::OutgoingPayment {
outgoing_payment_info,
} => Ok(outgoing_payment_info),
Activity::OfferClaim { .. } => invalid_input!("OfferClaim was found"),
Activity::Swap { .. } => invalid_input!("Swap was found"),
Activity::ReverseSwap {
outgoing_payment_info,
..
} => Ok(outgoing_payment_info),
Activity::ChannelClose { .. } => invalid_input!("ChannelClose was found"),
}
}
pub fn get_activity(&self, hash: String) -> Result<Activity> {
let payment = self
.rt
.handle()
.block_on(self.sdk.payment_by_hash(hash))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get payment by hash",
)?
.ok_or_invalid_input("No activity with provided hash was found")?;
self.activity_from_breez_ln_payment(payment)
}
pub fn set_payment_personal_note(&self, payment_hash: String, note: String) -> Result<()> {
let note = Some(note.trim().to_string()).filter(|s| !s.is_empty());
self.data_store
.lock_unwrap()
.update_personal_note(&payment_hash, note.as_deref())
}
fn activity_from_breez_payment(
&self,
breez_payment: breez_sdk_core::Payment,
) -> Result<Activity> {
match &breez_payment.details {
PaymentDetails::Ln { .. } => self.activity_from_breez_ln_payment(breez_payment),
PaymentDetails::ClosedChannel { data } => {
self.activity_from_breez_closed_channel_payment(&breez_payment, data)
}
}
}
fn activity_from_breez_ln_payment(
&self,
breez_payment: breez_sdk_core::Payment,
) -> Result<Activity> {
let payment_details = match breez_payment.details {
PaymentDetails::Ln { ref data } => data,
PaymentDetails::ClosedChannel { .. } => {
invalid_input!("PaymentInfo cannot be created from channel close")
}
};
let local_payment_data = self
.data_store
.lock_unwrap()
.retrieve_payment_info(&payment_details.payment_hash)?;
let (exchange_rate, tz_config, personal_note, offer, received_on, received_lnurl_comment) =
match local_payment_data {
Some(data) => (
Some(data.exchange_rate),
data.user_preferences.timezone_config,
data.personal_note,
data.offer,
data.received_on,
data.received_lnurl_comment,
),
None => (
self.get_exchange_rate(),
self.user_preferences.lock_unwrap().timezone_config.clone(),
None,
None,
None,
None,
),
};
if let Some(offer) = offer {
let incoming_payment_info = IncomingPaymentInfo::new(
breez_payment,
&exchange_rate,
tz_config,
personal_note,
received_on,
received_lnurl_comment,
&self.config.remote_services_config.lipa_lightning_domain,
)?;
let offer_kind = fill_payout_fee(
offer,
incoming_payment_info.requested_amount.sats.as_msats(),
&exchange_rate,
);
Ok(Activity::OfferClaim {
incoming_payment_info,
offer_kind,
})
} else if let Some(ref s) = payment_details.swap_info {
let swap_info = SwapInfo {
bitcoin_address: s.bitcoin_address.clone(),
created_at: unix_timestamp_to_system_time(s.created_at as u64)
.with_timezone(tz_config.clone()),
paid_amount: s.paid_msat.as_msats().to_amount_down(&exchange_rate),
txid: s
.confirmed_tx_ids
.first()
.ok_or(permanent_failure("Confirmed swap has no confirmed txid"))?
.clone(),
};
let incoming_payment_info = IncomingPaymentInfo::new(
breez_payment,
&exchange_rate,
tz_config,
personal_note,
received_on,
received_lnurl_comment,
&self.config.remote_services_config.lipa_lightning_domain,
)?;
Ok(Activity::Swap {
incoming_payment_info: Some(incoming_payment_info),
swap_info,
})
} else if let Some(ref s) = payment_details.reverse_swap_info {
let reverse_swap_info = ReverseSwapInfo {
paid_onchain_amount: s.onchain_amount_sat.as_sats().to_amount_up(&exchange_rate),
swap_fees_amount: (breez_payment.amount_msat
- s.onchain_amount_sat.as_sats().msats)
.as_msats()
.to_amount_up(&exchange_rate),
claim_txid: s.claim_txid.clone(),
status: s.status,
};
let outgoing_payment_info = OutgoingPaymentInfo::new(
breez_payment,
&exchange_rate,
tz_config,
personal_note,
&self.config.remote_services_config.lipa_lightning_domain,
)?;
Ok(Activity::ReverseSwap {
outgoing_payment_info,
reverse_swap_info,
})
} else if breez_payment.payment_type == breez_sdk_core::PaymentType::Received {
let incoming_payment_info = IncomingPaymentInfo::new(
breez_payment,
&exchange_rate,
tz_config,
personal_note,
received_on,
received_lnurl_comment,
&self.config.remote_services_config.lipa_lightning_domain,
)?;
Ok(Activity::IncomingPayment {
incoming_payment_info,
})
} else if breez_payment.payment_type == breez_sdk_core::PaymentType::Sent {
let outgoing_payment_info = OutgoingPaymentInfo::new(
breez_payment,
&exchange_rate,
tz_config,
personal_note,
&self.config.remote_services_config.lipa_lightning_domain,
)?;
Ok(Activity::OutgoingPayment {
outgoing_payment_info,
})
} else {
permanent_failure!("Unreachable code")
}
}
fn activity_from_breez_closed_channel_payment(
&self,
breez_payment: &breez_sdk_core::Payment,
details: &ClosedChannelPaymentDetails,
) -> Result<Activity> {
let amount = breez_payment
.amount_msat
.as_msats()
.to_amount_up(&self.get_exchange_rate());
let user_preferences = self.user_preferences.lock_unwrap();
let time = unix_timestamp_to_system_time(breez_payment.payment_time as u64)
.with_timezone(user_preferences.timezone_config.clone());
let (closed_at, state) = match breez_payment.status {
PaymentStatus::Pending => (None, ChannelCloseState::Pending),
PaymentStatus::Complete => (Some(time), ChannelCloseState::Confirmed),
PaymentStatus::Failed => {
permanent_failure!("A channel close Breez Payment has status *Failed*");
}
};
let closing_tx_id = details.closing_txid.clone().unwrap_or_default();
Ok(Activity::ChannelClose {
channel_close_info: ChannelCloseInfo {
amount,
state,
closed_at,
closing_tx_id,
},
})
}
fn payment_from_created_invoice(
&self,
created_invoice: &CreatedInvoice,
) -> Result<IncomingPaymentInfo> {
let invoice =
parse_invoice(created_invoice.invoice.as_str()).map_to_permanent_failure(format!(
"Invalid invoice obtained from local db: {}",
created_invoice.invoice
))?;
let invoice_details = InvoiceDetails::from_ln_invoice(invoice.clone(), &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 invoice_details = InvoiceDetails::from_ln_invoice(invoice, &exchange_rate);
let time = invoice_details
.creation_timestamp
.with_timezone(local_payment_data.user_preferences.timezone_config);
let lsp_fees = created_invoice
.channel_opening_fees
.unwrap_or_default()
.as_msats()
.to_amount_up(&exchange_rate);
let requested_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);
let amount = requested_amount.clone().sats - lsp_fees.sats;
let amount = amount.as_sats().to_amount_down(&exchange_rate);
let personal_note = local_payment_data.personal_note;
let payment_info = PaymentInfo {
payment_state,
hash: invoice_details.payment_hash.clone(),
amount,
invoice_details: invoice_details.clone(),
created_at: time,
description: invoice_details.description,
preimage: None,
personal_note,
};
let incoming_payment_info = IncomingPaymentInfo {
payment_info,
requested_amount,
lsp_fees,
received_on: None,
received_lnurl_comment: None,
};
Ok(incoming_payment_info)
}
pub fn foreground(&self) {
self.task_manager.lock_unwrap().foreground();
}
pub fn background(&self) {
self.task_manager.lock_unwrap().background();
}
pub fn list_currency_codes(&self) -> Vec<String> {
let rates = self.task_manager.lock_unwrap().get_exchange_rates();
rates.iter().map(|r| r.currency_code.clone()).collect()
}
pub fn get_exchange_rate(&self) -> Option<ExchangeRate> {
let rates = self.task_manager.lock_unwrap().get_exchange_rates();
let currency_code = self.user_preferences.lock_unwrap().fiat_currency.clone();
rates
.iter()
.find(|r| r.currency_code == currency_code)
.cloned()
}
pub fn change_fiat_currency(&self, fiat_currency: String) {
self.user_preferences.lock_unwrap().fiat_currency = fiat_currency;
}
pub fn change_timezone_config(&self, timezone_config: TzConfig) {
self.user_preferences.lock_unwrap().timezone_config = timezone_config;
}
pub fn accept_pocket_terms_and_conditions(
&self,
version: i64,
fingerprint: String,
) -> Result<()> {
self.auth
.accept_terms_and_conditions(TermsAndConditions::Pocket, version, fingerprint)
.map_runtime_error_to(RuntimeErrorCode::AuthServiceUnavailable)
}
pub fn get_terms_and_conditions_status(
&self,
terms_and_conditions: TermsAndConditions,
) -> Result<TermsAndConditionsStatus> {
self.auth
.get_terms_and_conditions_status(terms_and_conditions)
.map_runtime_error_to(RuntimeErrorCode::AuthServiceUnavailable)
}
pub fn register_fiat_topup(
&self,
email: Option<String>,
user_iban: String,
user_currency: String,
) -> Result<FiatTopupInfo> {
debug!("register_fiat_topup() - called with - email: {email:?} - user_iban: {user_iban} - user_currency: {user_currency:?}");
user_iban
.parse::<Iban>()
.map_to_invalid_input("Invalid user_iban")?;
if let Some(email) = email.as_ref() {
EmailAddress::from_str(email).map_to_invalid_input("Invalid email")?;
}
let sdk = Arc::clone(&self.sdk);
let sign_message = |message| async move {
sdk.sign_message(SignMessageRequest { message })
.await
.ok()
.map(|r| r.signature)
};
let topup_info = self
.rt
.handle()
.block_on(self.fiat_topup_client.register_pocket_fiat_topup(
&user_iban,
user_currency,
self.get_node_info()?.node_pubkey,
sign_message,
))
.map_to_runtime_error(
RuntimeErrorCode::OfferServiceUnavailable,
"Failed to register pocket fiat topup",
)?;
self.data_store
.lock_unwrap()
.store_fiat_topup_info(topup_info.clone())?;
self.offer_manager
.register_topup(topup_info.order_id.clone(), email)
.map_runtime_error_to(RuntimeErrorCode::OfferServiceUnavailable)?;
Ok(topup_info)
}
pub fn reset_fiat_topup(&self) -> Result<()> {
self.data_store.lock_unwrap().clear_fiat_topup_info()
}
pub fn hide_topup(&self, id: String) -> Result<()> {
self.offer_manager
.hide_topup(id)
.map_runtime_error_to(RuntimeErrorCode::OfferServiceUnavailable)
}
pub fn list_action_required_items(&self) -> Result<Vec<ActionRequiredItem>> {
let uncompleted_offers = self.query_uncompleted_offers()?;
let sat_per_vbyte = self.query_onchain_fee_rate()?;
let hidden_failed_swap_addresses = self
.data_store
.lock_unwrap()
.retrieve_hidden_unresolved_failed_swaps()?;
let failed_swaps: Vec<_> = self
.get_unresolved_failed_swaps()?
.into_iter()
.filter(|s| {
hidden_failed_swap_addresses.contains(&s.address).not()
|| self
.prepare_resolve_failed_swap(
s.clone(),
"1BitcoinEaterAddressDontSendf59kuE".to_string(),
sat_per_vbyte * 2,
)
.is_ok()
})
.collect();
let available_channel_closes_funds = self.get_node_info()?.onchain_balance;
let mut action_required_items: Vec<ActionRequiredItem> = uncompleted_offers
.into_iter()
.map(Into::into)
.chain(failed_swaps.into_iter().map(Into::into))
.collect();
if available_channel_closes_funds.sats > CLN_DUST_LIMIT_SAT {
let utxos = self.get_node_utxos()?;
let available_funds_sats = if utxos
.iter()
.any(|u| u.amount_millisatoshi == CLN_DUST_LIMIT_SAT * 1_000)
{
available_channel_closes_funds.sats
} else {
available_channel_closes_funds.sats - CLN_DUST_LIMIT_SAT
};
let optional_hidden_amount_sat = self
.data_store
.lock_unwrap()
.retrieve_hidden_channel_close_onchain_funds_amount_sat()?;
let include_item_in_list = match optional_hidden_amount_sat {
Some(amount) if amount == available_channel_closes_funds.sats => {
self.get_channel_close_resolving_fees()?.is_some()
}
_ => true,
};
if include_item_in_list {
action_required_items.push(ActionRequiredItem::ChannelClosesFundsAvailable {
available_funds: available_funds_sats
.as_sats()
.to_amount_down(&self.get_exchange_rate()),
});
}
}
Ok(action_required_items)
}
pub fn hide_channel_closes_funds_available_action_required_item(&self) -> Result<()> {
let onchain_balance_sat = self.get_node_info()?.onchain_balance.sats;
self.data_store
.lock_unwrap()
.store_hidden_channel_close_onchain_funds_amount_sat(onchain_balance_sat)?;
Ok(())
}
pub fn hide_unresolved_failed_swap_action_required_item(
&self,
failed_swap_info: FailedSwapInfo,
) -> Result<()> {
self.data_store
.lock_unwrap()
.store_hidden_unresolved_failed_swap(&failed_swap_info.address)?;
Ok(())
}
pub fn query_uncompleted_offers(&self) -> Result<Vec<OfferInfo>> {
let topup_infos = self
.offer_manager
.query_uncompleted_topups()
.map_runtime_error_to(RuntimeErrorCode::OfferServiceUnavailable)?;
let rate = self.get_exchange_rate();
let list_payments_request = ListPaymentsRequest {
filters: Some(vec![PaymentTypeFilter::Received]),
metadata_filters: None,
from_timestamp: None,
to_timestamp: None,
include_failures: Some(false),
limit: Some(5),
offset: None,
};
let latest_activities = 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.status == PaymentStatus::Complete)
.map(|p| self.activity_from_breez_payment(p))
.filter_map(filter_out_and_log_corrupted_activities)
.collect::<Vec<_>>();
Ok(
filter_out_recently_claimed_topups(topup_infos, latest_activities)
.into_iter()
.map(|topup_info| OfferInfo::from(topup_info, &rate))
.collect(),
)
}
pub fn calculate_lightning_payout_fee(&self, offer: OfferInfo) -> Result<Amount> {
ensure!(
offer.status != OfferStatus::REFUNDED && offer.status != OfferStatus::SETTLED,
invalid_input(format!("Provided offer is already completed: {offer:?}"))
);
let max_withdrawable_msats = match self.rt.handle().block_on(parse(
&offer
.lnurlw
.ok_or_permanent_failure("Uncompleted offer didn't include an lnurlw")?,
)) {
Ok(InputType::LnUrlWithdraw { data }) => data,
Ok(input_type) => {
permanent_failure!("Invalid input type LNURLw in uncompleted offer: {input_type:?}")
}
Err(err) => {
permanent_failure!("Invalid LNURLw in uncompleted offer: {err}")
}
}
.max_withdrawable;
ensure!(
max_withdrawable_msats <= offer.amount.sats.as_sats().msats,
permanent_failure("LNURLw provides more")
);
let exchange_rate = self.get_exchange_rate();
Ok((offer.amount.sats.as_sats().msats - max_withdrawable_msats)
.as_msats()
.to_amount_up(&exchange_rate))
}
pub fn request_offer_collection(&self, offer: OfferInfo) -> Result<String> {
let lnurlw_data = match self.rt.handle().block_on(parse(
&offer
.lnurlw
.ok_or_invalid_input("The provided offer didn't include an lnurlw")?,
)) {
Ok(InputType::LnUrlWithdraw { data }) => data,
Ok(input_type) => {
permanent_failure!("Invalid input type LNURLw in offer: {input_type:?}")
}
Err(err) => permanent_failure!("Invalid LNURLw in offer: {err}"),
};
let collectable_amount = lnurlw_data.max_withdrawable;
let hash = match self
.rt
.handle()
.block_on(self.sdk.lnurl_withdraw(LnUrlWithdrawRequest {
data: lnurlw_data,
amount_msat: collectable_amount,
description: None,
})) {
Ok(breez_sdk_core::LnUrlWithdrawResult::Ok { data }) => data.invoice.payment_hash,
Ok(breez_sdk_core::LnUrlWithdrawResult::Timeout { .. }) => runtime_error!(
RuntimeErrorCode::OfferServiceUnavailable,
"Failed to withdraw offer due to timeout on submitting invoice"
),
Ok(breez_sdk_core::LnUrlWithdrawResult::ErrorStatus { data }) => runtime_error!(
RuntimeErrorCode::OfferServiceUnavailable,
"Failed to withdraw offer due to: {}",
data.reason
),
Err(breez_sdk_core::LnUrlWithdrawError::Generic { err }) => runtime_error!(
RuntimeErrorCode::OfferServiceUnavailable,
"Failed to withdraw offer due to: {err}"
),
Err(breez_sdk_core::LnUrlWithdrawError::InvalidAmount { err }) => {
permanent_failure!("Invalid amount in invoice for LNURL withdraw: {err}")
}
Err(breez_sdk_core::LnUrlWithdrawError::InvalidInvoice { err }) => {
permanent_failure!("Invalid invoice for LNURL withdraw: {err}")
}
Err(breez_sdk_core::LnUrlWithdrawError::InvalidUri { err }) => {
permanent_failure!("Invalid URL in LNURL withdraw: {err}")
}
Err(breez_sdk_core::LnUrlWithdrawError::ServiceConnectivity { err }) => {
runtime_error!(
RuntimeErrorCode::OfferServiceUnavailable,
"Failed to withdraw offer due to: {err}"
)
}
Err(breez_sdk_core::LnUrlWithdrawError::InvoiceNoRoutingHints { err }) => {
permanent_failure!(
"A locally created invoice doesn't have any routing hints: {err}"
)
}
};
#[allow(irrefutable_let_patterns)]
#[cfg(feature = "mock-deps")]
if let OfferKind::Pocket { id, .. } = offer.offer_kind.clone() {
self.offer_manager.hide_topup(id).unwrap();
}
self.store_payment_info(&hash, Some(offer.offer_kind));
Ok(hash)
}
pub fn register_notification_token(
&self,
notification_token: String,
language_iso_639_1: String,
country_iso_3166_1_alpha_2: String,
) -> Result<()> {
let language = LanguageCode::from_str(&language_iso_639_1.to_lowercase())
.map_to_invalid_input("Invalid language code")?;
let country = CountryCode::for_alpha2(&country_iso_3166_1_alpha_2.to_uppercase())
.map_to_invalid_input("Invalid country code")?;
self.offer_manager
.register_notification_token(notification_token, language, country)
.map_runtime_error_to(RuntimeErrorCode::OfferServiceUnavailable)
}
pub fn get_wallet_pubkey_id(&self) -> Result<String> {
self.auth.get_wallet_pubkey_id().map_to_runtime_error(
RuntimeErrorCode::AuthServiceUnavailable,
"Failed to authenticate in order to get the wallet pubkey id",
)
}
pub fn get_payment_uuid(&self, payment_hash: String) -> Result<String> {
get_payment_uuid(payment_hash)
}
fn store_payment_info(&self, hash: &str, offer: Option<OfferKind>) {
let user_preferences = self.user_preferences.lock_unwrap().clone();
let exchange_rates = self.task_manager.lock_unwrap().get_exchange_rates();
self.data_store
.lock_unwrap()
.store_payment_info(hash, user_preferences, exchange_rates, offer, None, None)
.log_ignore_error(Level::Error, "Failed to persist payment info")
}
pub fn query_onchain_fee_rate(&self) -> Result<u32> {
let recommended_fees = self
.rt
.handle()
.block_on(self.sdk.recommended_fees())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Couldn't fetch recommended fees",
)?;
Ok(recommended_fees.half_hour_fee as u32)
}
pub fn prepare_sweep_funds_from_channel_closes(
&self,
address: String,
onchain_fee_rate: u32,
) -> std::result::Result<SweepInfo, RedeemOnchainError> {
let res =
self.rt
.handle()
.block_on(self.sdk.prepare_redeem_onchain_funds(
PrepareRedeemOnchainFundsRequest {
to_address: address.clone(),
sat_per_vbyte: onchain_fee_rate,
},
))?;
let onchain_balance_sat = self
.sdk
.node_info()
.map_err(|e| RedeemOnchainError::ServiceConnectivity {
err: format!("Failed to fetch on-chain balance: {e}"),
})?
.onchain_balance_msat
.as_msats()
.to_amount_down(&None)
.sats;
let rate = self.get_exchange_rate();
let utxos = self
.get_node_utxos()
.map_err(|e| RedeemOnchainError::Generic { err: e.to_string() })?;
let onchain_fee_sat = if utxos
.iter()
.any(|u| u.amount_millisatoshi == CLN_DUST_LIMIT_SAT * 1_000)
{
res.tx_fee_sat
} else {
res.tx_fee_sat + CLN_DUST_LIMIT_SAT
};
let onchain_fee_amount = onchain_fee_sat.as_sats().to_amount_up(&rate);
Ok(SweepInfo {
address,
onchain_fee_rate,
onchain_fee_amount,
amount: (onchain_balance_sat - res.tx_fee_sat)
.as_sats()
.to_amount_up(&rate),
})
}
pub fn sweep_funds_from_channel_closes(&self, sweep_info: SweepInfo) -> Result<String> {
let txid = self
.rt
.handle()
.block_on(self.sdk.redeem_onchain_funds(RedeemOnchainFundsRequest {
to_address: sweep_info.address,
sat_per_vbyte: sweep_info.onchain_fee_rate,
}))
.map_to_runtime_error(RuntimeErrorCode::NodeUnavailable, "Failed to sweep funds")?
.txid;
Ok(hex::encode(txid))
}
pub fn generate_swap_address(
&self,
lsp_fee_params: Option<OpeningFeeParams>,
) -> std::result::Result<SwapAddressInfo, ReceiveOnchainError> {
let swap_info =
self.rt
.handle()
.block_on(self.sdk.receive_onchain(ReceiveOnchainRequest {
opening_fee_params: lsp_fee_params,
}))?;
let rate = self.get_exchange_rate();
Ok(SwapAddressInfo {
address: swap_info.bitcoin_address,
min_deposit: (swap_info.min_allowed_deposit as u64)
.as_sats()
.to_amount_up(&rate),
max_deposit: (swap_info.max_allowed_deposit as u64)
.as_sats()
.to_amount_down(&rate),
swap_fee: 0_u64.as_sats().to_amount_up(&rate),
})
}
pub fn get_unresolved_failed_swaps(&self) -> Result<Vec<FailedSwapInfo>> {
Ok(self
.rt
.handle()
.block_on(self.sdk.list_refundables())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to list refundable failed swaps",
)?
.into_iter()
.map(|s| FailedSwapInfo {
address: s.bitcoin_address,
amount: s
.confirmed_sats
.as_sats()
.to_amount_down(&self.get_exchange_rate()),
created_at: unix_timestamp_to_system_time(s.created_at as u64),
})
.collect())
}
pub fn get_failed_swap_resolving_fees(
&self,
failed_swap_info: FailedSwapInfo,
) -> Result<Option<OnchainResolvingFees>> {
let sdk = Arc::clone(&self.sdk);
let handle = self.rt.handle();
let swap_address = failed_swap_info.address;
let prepare_onchain_tx =
move |to_address: String, sat_per_vbyte: u32| -> Result<(Sats, Sats)> {
let prepare_refund_response = handle
.block_on(sdk.prepare_refund(PrepareRefundRequest {
swap_address,
to_address,
sat_per_vbyte,
}))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to prepare refund",
)?;
let sent_amount = (failed_swap_info.amount.sats
- prepare_refund_response.refund_tx_fee_sat)
.as_sats();
let onchain_fee = prepare_refund_response.refund_tx_fee_sat.as_sats();
Ok((sent_amount, onchain_fee))
};
self.get_onchain_resolving_fees(failed_swap_info.amount.sats.as_msats(), prepare_onchain_tx)
}
fn get_onchain_resolving_fees<F>(
&self,
amount: Msats,
prepare_onchain_tx: F,
) -> Result<Option<OnchainResolvingFees>>
where
F: FnOnce(String, u32) -> Result<(Sats, Sats)>,
{
let rate = self.get_exchange_rate();
let lsp_fees = self.calculate_lsp_fee(amount.msats)?;
let swap_info = self
.rt
.handle()
.block_on(self.sdk.receive_onchain(ReceiveOnchainRequest {
opening_fee_params: lsp_fees.lsp_fee_params,
}))
.ok();
let sat_per_vbyte = self.query_onchain_fee_rate()?;
let (sent_amount, onchain_fee) = match prepare_onchain_tx(
swap_info
.clone()
.map(|s| s.bitcoin_address)
.unwrap_or("1BitcoinEaterAddressDontSendf59kuE".to_string()),
sat_per_vbyte,
) {
Ok(t) => t,
Err(e) => {
error!("Failed to prepare onchain tx due to {e}");
return Ok(None);
}
};
if onchain_fee.sats * 2 > amount.sats_round_down().sats {
return Ok(None);
}
let lsp_fees = self.calculate_lsp_fee(sent_amount.sats)?;
if swap_info.is_none()
|| sent_amount.sats < (swap_info.clone().unwrap().min_allowed_deposit as u64)
|| sent_amount.sats > (swap_info.clone().unwrap().max_allowed_deposit as u64)
|| sent_amount.sats <= lsp_fees.lsp_fee.sats
{
return Ok(Some(OnchainResolvingFees {
swap_fees: None,
sweep_onchain_fee_estimate: onchain_fee.to_amount_up(&rate),
sat_per_vbyte,
}));
}
let swap_fee = 0_u64.as_sats();
let swap_to_lightning_fees = SwapToLightningFees {
swap_fee: swap_fee.sats.as_sats().to_amount_up(&rate),
onchain_fee: onchain_fee.to_amount_up(&rate),
channel_opening_fee: lsp_fees.lsp_fee.clone(),
total_fees: (swap_fee.sats + onchain_fee.sats + lsp_fees.lsp_fee.sats)
.as_sats()
.to_amount_up(&rate),
lsp_fee_params: lsp_fees.lsp_fee_params,
};
Ok(Some(OnchainResolvingFees {
swap_fees: Some(swap_to_lightning_fees),
sweep_onchain_fee_estimate: onchain_fee.to_amount_up(&rate),
sat_per_vbyte,
}))
}
pub fn prepare_resolve_failed_swap(
&self,
failed_swap_info: FailedSwapInfo,
to_address: String,
onchain_fee_rate: u32,
) -> Result<ResolveFailedSwapInfo> {
let response = self
.rt
.handle()
.block_on(self.sdk.prepare_refund(PrepareRefundRequest {
swap_address: failed_swap_info.address.clone(),
to_address: to_address.clone(),
sat_per_vbyte: onchain_fee_rate,
}))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to prepare a failed swap refund transaction",
)?;
let rate = self.get_exchange_rate();
let onchain_fee = response.refund_tx_fee_sat.as_sats().to_amount_up(&rate);
let recovered_amount = (failed_swap_info.amount.sats - onchain_fee.sats)
.as_sats()
.to_amount_down(&rate);
Ok(ResolveFailedSwapInfo {
swap_address: failed_swap_info.address,
recovered_amount,
onchain_fee,
to_address,
onchain_fee_rate,
})
}
pub fn resolve_failed_swap(
&self,
resolve_failed_swap_info: ResolveFailedSwapInfo,
) -> Result<String> {
Ok(self
.rt
.handle()
.block_on(self.sdk.refund(RefundRequest {
swap_address: resolve_failed_swap_info.swap_address,
to_address: resolve_failed_swap_info.to_address,
sat_per_vbyte: resolve_failed_swap_info.onchain_fee_rate,
}))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to create and broadcast failed swap refund transaction",
)?
.refund_tx_id)
}
pub fn swap_failed_swap_funds_to_lightning(
&self,
failed_swap_info: FailedSwapInfo,
sat_per_vbyte: u32,
lsp_fee_param: Option<OpeningFeeParams>,
) -> Result<String> {
let swap_address_info = self
.generate_swap_address(lsp_fee_param.clone())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Couldn't generate swap address",
)?;
let prepare_response = self
.rt
.handle()
.block_on(self.sdk.prepare_refund(PrepareRefundRequest {
swap_address: failed_swap_info.address.clone(),
to_address: swap_address_info.address.clone(),
sat_per_vbyte,
}))
.map_to_runtime_error(RuntimeErrorCode::NodeUnavailable, "Coudln't prepare refund")?;
let send_amount_sats = failed_swap_info.amount.sats - prepare_response.refund_tx_fee_sat;
ensure!(
swap_address_info.min_deposit.sats <= send_amount_sats,
runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed swap amount isn't enough for creating new swap"
)
);
ensure!(
swap_address_info.max_deposit.sats >= send_amount_sats,
runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed swap amount is too big for creating new swap"
)
);
let lsp_fees = self.calculate_lsp_fee(send_amount_sats)?.lsp_fee.sats;
ensure!(
lsp_fees < send_amount_sats,
runtime_error(
RuntimeErrorCode::NodeUnavailable,
"A new channel is needed and the failed swap amount is not enough to pay for fees"
)
);
let refund_response = self
.rt
.handle()
.block_on(self.sdk.refund(RefundRequest {
swap_address: failed_swap_info.address,
to_address: swap_address_info.address,
sat_per_vbyte,
}))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Couldn't broadcast swap refund transaction",
)?;
Ok(refund_response.refund_tx_id)
}
pub fn get_channel_close_resolving_fees(&self) -> Result<Option<OnchainResolvingFees>> {
let onchain_balance = self
.sdk
.node_info()
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Couldn't fetch on-chain balance",
)?
.onchain_balance_msat
.as_msats();
ensure!(
onchain_balance.msats != 0,
invalid_input("No on-chain funds to resolve")
);
let prepare_onchain_tx =
move |to_address: String, sat_per_vbyte: u32| -> Result<(Sats, Sats)> {
let sweep_info = self
.prepare_sweep_funds_from_channel_closes(to_address, sat_per_vbyte)
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to prepare sweep funds from channel closes",
)?;
Ok((
sweep_info.amount.sats.as_sats(),
sweep_info.onchain_fee_amount.sats.as_sats(),
))
};
self.get_onchain_resolving_fees(onchain_balance, prepare_onchain_tx)
}
pub fn swap_channel_close_funds_to_lightning(
&self,
sat_per_vbyte: u32,
lsp_fee_params: Option<OpeningFeeParams>,
) -> std::result::Result<String, RedeemOnchainError> {
let onchain_balance = self.sdk.node_info()?.onchain_balance_msat.as_msats();
let swap_address_info =
self.generate_swap_address(lsp_fee_params.clone())
.map_err(|e| RedeemOnchainError::Generic {
err: format!("Couldn't generate swap address: {}", e),
})?;
let prepare_response =
self.rt
.handle()
.block_on(self.sdk.prepare_redeem_onchain_funds(
PrepareRedeemOnchainFundsRequest {
to_address: swap_address_info.address.clone(),
sat_per_vbyte,
},
))?;
let send_amount_sats = onchain_balance.sats_round_down().sats
- CLN_DUST_LIMIT_SAT
- prepare_response.tx_fee_sat;
if swap_address_info.min_deposit.sats > send_amount_sats {
return Err(RedeemOnchainError::InsufficientFunds {
err: format!(
"Not enough funds ({} sats after onchain fees) available for min swap amount({} sats)",
send_amount_sats,
swap_address_info.min_deposit.sats,
),
});
}
if swap_address_info.max_deposit.sats < send_amount_sats {
return Err(RedeemOnchainError::Generic {
err: format!(
"Available funds ({} sats after onchain fees) exceed limit for swap ({} sats)",
send_amount_sats, swap_address_info.max_deposit.sats,
),
});
}
let lsp_fees = self
.calculate_lsp_fee(send_amount_sats)
.map_err(|_| RedeemOnchainError::ServiceConnectivity {
err: "Could not get lsp fees".to_string(),
})?
.lsp_fee
.sats;
if lsp_fees >= send_amount_sats {
return Err(RedeemOnchainError::InsufficientFunds {
err: format!(
"Available funds ({} sats after onchain fees) are not enough for lsp fees ({} sats)",
send_amount_sats, lsp_fees,
),
});
}
let sweep_result = self.rt.handle().block_on(self.sdk.redeem_onchain_funds(
RedeemOnchainFundsRequest {
to_address: swap_address_info.address,
sat_per_vbyte,
},
))?;
Ok(hex::encode(sweep_result.txid))
}
pub fn log_debug_info(&self) -> Result<()> {
self.rt
.handle()
.block_on(self.sdk.sync())
.log_ignore_error(Level::Error, "Failed to sync node");
let available_lsps = self
.rt
.handle()
.block_on(self.sdk.list_lsps())
.map_to_runtime_error(RuntimeErrorCode::NodeUnavailable, "Couldn't list lsps")?;
let connected_lsp = self
.rt
.handle()
.block_on(self.sdk.lsp_id())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get current lsp id",
)?
.unwrap_or("<no connection>".to_string());
let node_state = self.sdk.node_info().map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to read node info",
)?;
let channels = self
.rt
.handle()
.block_on(self.sdk.execute_dev_command("listpeerchannels".to_string()))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Couldn't execute `listpeerchannels` command",
)?;
let payments = self
.rt
.handle()
.block_on(self.sdk.execute_dev_command("listpayments".to_string()))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Couldn't execute `listpayments` command",
)?;
let diagnostics = self
.rt
.handle()
.block_on(self.sdk.generate_diagnostic_data())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Couldn't call generate_diagnostic_data",
)?;
info!("3L version: {}", env!("GITHUB_REF"));
info!("Wallet pubkey id: {:?}", self.get_wallet_pubkey_id());
info!("Node state:\n{node_state:?}");
info!(
"List of available lsps:\n{}",
replace_byte_arrays_by_hex_string(&format!("{available_lsps:?}"))
);
info!("Connected lsp id: {connected_lsp}");
info!(
"List of peer channels:\n{}",
replace_byte_arrays_by_hex_string(&channels)
);
info!(
"List of payments:\n{}",
replace_byte_arrays_by_hex_string(&payments)
);
info!("Diagnostic data:\n{diagnostics}");
Ok(())
}
pub fn retrieve_latest_fiat_topup_info(&self) -> Result<Option<FiatTopupInfo>> {
self.data_store
.lock_unwrap()
.retrieve_latest_fiat_topup_info()
}
pub fn get_health_status(&self) -> Result<BreezHealthCheckStatus> {
Ok(self
.rt
.handle()
.block_on(BreezServices::service_health_check(
self.config.breez_sdk_config.breez_sdk_api_key.clone(),
))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get health status",
)?
.status)
}
pub fn check_clear_wallet_feasibility(&self) -> Result<RangeHit> {
let limits = self
.rt
.handle()
.block_on(self.sdk.onchain_payment_limits())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get on-chain payment limits",
)?;
let balance_sat = self
.sdk
.node_info()
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to read node info",
)?
.channels_balance_msat
.as_msats()
.sats_round_down()
.sats;
let exchange_rate = self.get_exchange_rate();
let routing_fee = Permyriad(self.config.max_routing_fee_config.max_routing_fee_permyriad)
.of(&limits.min_sat.as_sats())
.sats_round_up();
let min = limits.min_sat + routing_fee.sats;
let range_hit = match balance_sat {
balance_sat if balance_sat < min => RangeHit::Below {
min: min.as_sats().to_amount_up(&exchange_rate),
},
balance_sat if balance_sat <= limits.max_sat => RangeHit::In,
balance_sat if limits.max_sat < balance_sat => RangeHit::Above {
max: limits.max_sat.as_sats().to_amount_down(&exchange_rate),
},
_ => permanent_failure!("Unreachable code in check_clear_wallet_feasibility()"),
};
Ok(range_hit)
}
pub fn prepare_clear_wallet(&self) -> Result<ClearWalletInfo> {
let claim_tx_feerate = self.query_onchain_fee_rate()?;
let limits = self
.rt
.handle()
.block_on(self.sdk.onchain_payment_limits())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to get on-chain payment limits",
)?;
let prepare_response = self
.rt
.handle()
.block_on(
self.sdk
.prepare_onchain_payment(PrepareOnchainPaymentRequest {
amount_sat: limits.max_payable_sat,
amount_type: breez_sdk_core::SwapAmountType::Send,
claim_tx_feerate,
}),
)
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to prepare on-chain payment",
)?;
let total_fees_sat = prepare_response.total_fees;
let onchain_fee_sat = prepare_response.fees_claim + prepare_response.fees_lockup;
let swap_fee_sat = total_fees_sat - onchain_fee_sat;
let exchange_rate = self.get_exchange_rate();
Ok(ClearWalletInfo {
clear_amount: prepare_response
.sender_amount_sat
.as_sats()
.to_amount_up(&exchange_rate),
total_estimated_fees: total_fees_sat.as_sats().to_amount_up(&exchange_rate),
onchain_fee: onchain_fee_sat.as_sats().to_amount_up(&exchange_rate),
swap_fee: swap_fee_sat.as_sats().to_amount_up(&exchange_rate),
prepare_response,
})
}
pub fn clear_wallet(
&self,
clear_wallet_info: ClearWalletInfo,
destination_onchain_address_data: BitcoinAddressData,
) -> Result<()> {
self.rt
.handle()
.block_on(self.sdk.pay_onchain(PayOnchainRequest {
recipient_address: destination_onchain_address_data.address,
prepare_res: clear_wallet_info.prepare_response,
}))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to start reverse swap",
)?;
Ok(())
}
pub fn set_analytics_config(&self, config: AnalyticsConfig) -> Result<()> {
*self.analytics_interceptor.config.lock_unwrap() = config.clone();
self.data_store
.lock_unwrap()
.append_analytics_config(config)
}
pub fn get_analytics_config(&self) -> Result<AnalyticsConfig> {
self.data_store.lock_unwrap().retrieve_analytics_config()
}
pub fn register_lightning_address(&self) -> Result<String> {
let address = self
.rt
.handle()
.block_on(pigeon::assign_lightning_address(
&self.config.remote_services_config.backend_url,
&self.async_auth,
))
.map_to_runtime_error(
RuntimeErrorCode::AuthServiceUnavailable,
"Failed to register a lightning address",
)?;
self.data_store
.lock_unwrap()
.store_lightning_address(&address)?;
Ok(address)
}
pub fn query_lightning_address(&self) -> Result<Option<String>> {
Ok(self
.data_store
.lock_unwrap()
.retrieve_lightning_addresses()?
.into_iter()
.filter_map(with_status(EnableStatus::Enabled))
.find(|a| !a.starts_with('-')))
}
pub fn query_verified_phone_number(&self) -> Result<Option<String>> {
Ok(self
.data_store
.lock_unwrap()
.retrieve_lightning_addresses()?
.into_iter()
.filter_map(with_status(EnableStatus::Enabled))
.find(|a| a.starts_with('-'))
.and_then(|a| {
lightning_address_to_phone_number(
&a,
&self.config.remote_services_config.lipa_lightning_domain,
)
}))
}
pub fn request_phone_number_verification(&self, phone_number: String) -> Result<()> {
let phone_number = self
.parse_phone_number(phone_number)
.map_to_invalid_input("Invalid phone number")?;
let encrypted_number = encrypt(
phone_number.e164.as_bytes(),
&self.persistence_encryption_key,
)?;
let encrypted_number = hex::encode(encrypted_number);
self.rt
.handle()
.block_on(pigeon::request_phone_number_verification(
&self.config.remote_services_config.backend_url,
&self.async_auth,
phone_number.e164,
encrypted_number,
))
.map_to_runtime_error(
RuntimeErrorCode::AuthServiceUnavailable,
"Failed to register phone number",
)
}
pub fn verify_phone_number(&self, phone_number: String, otp: String) -> Result<()> {
let phone_number = self
.parse_phone_number(phone_number)
.map_to_invalid_input("Invalid phone number")?;
self.rt
.handle()
.block_on(pigeon::verify_phone_number(
&self.config.remote_services_config.backend_url,
&self.async_auth,
phone_number.e164.clone(),
otp,
))
.map_to_runtime_error(
RuntimeErrorCode::AuthServiceUnavailable,
"Failed to submit phone number registration otp",
)?;
let address = phone_number
.to_lightning_address(&self.config.remote_services_config.lipa_lightning_domain);
self.data_store
.lock_unwrap()
.store_lightning_address(&address)
}
pub fn set_feature_flag(&self, feature: FeatureFlag, flag_enabled: bool) -> Result<()> {
let kind_of_address = match feature {
FeatureFlag::LightningAddress => |a: &String| !a.starts_with('-'),
FeatureFlag::PhoneNumber => |a: &String| a.starts_with('-'),
};
let (from_status, to_status) = match flag_enabled {
true => (EnableStatus::FeatureDisabled, EnableStatus::Enabled),
false => (EnableStatus::Enabled, EnableStatus::FeatureDisabled),
};
let addresses = self
.data_store
.lock_unwrap()
.retrieve_lightning_addresses()?
.into_iter()
.filter_map(with_status(from_status))
.filter(kind_of_address)
.collect::<Vec<_>>();
if addresses.is_empty() {
info!("No lightning addresses to change the status");
return Ok(());
}
let doing = match flag_enabled {
true => "Enabling",
false => "Disabling",
};
info!("{doing} {addresses:?} on the backend");
self.rt
.handle()
.block_on(async {
if flag_enabled {
pigeon::enable_lightning_addresses(
&self.config.remote_services_config.backend_url,
&self.async_auth,
addresses.clone(),
)
.await
} else {
pigeon::disable_lightning_addresses(
&self.config.remote_services_config.backend_url,
&self.async_auth,
addresses.clone(),
)
.await
}
})
.map_to_runtime_error(
RuntimeErrorCode::AuthServiceUnavailable,
"Failed to enable/disable a lightning address",
)?;
let mut data_store = self.data_store.lock_unwrap();
addresses
.into_iter()
.try_for_each(|a| data_store.update_lightning_address(&a, to_status))
}
fn report_send_payment_issue(&self, payment_hash: String) {
debug!("Reporting failure of payment: {payment_hash}");
let data = ReportPaymentFailureDetails {
payment_hash,
comment: None,
};
let request = ReportIssueRequest::PaymentFailure { data };
self.rt
.handle()
.block_on(self.sdk.report_issue(request))
.log_ignore_error(Level::Warn, "Failed to report issue");
}
fn get_node_utxos(&self) -> Result<Vec<UnspentTransactionOutput>> {
let node_state = self
.sdk
.node_info()
.map_to_runtime_error(RuntimeErrorCode::NodeUnavailable, "Couldn't get node info")?;
Ok(node_state.utxos)
}
#[doc(hidden)]
pub fn close_all_channels_with_current_lsp(&self) -> Result<()> {
self.rt
.handle()
.block_on(self.sdk.close_lsp_channels())
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to close channels",
)?;
Ok(())
}
}
pub(crate) async fn start_sdk(
config: &Config,
event_listener: Box<dyn EventListener>,
) -> Result<Arc<BreezServices>> {
let developer_cert = config
.breez_sdk_config
.breez_sdk_partner_certificate
.as_bytes()
.to_vec();
let developer_key = config
.breez_sdk_config
.breez_sdk_partner_key
.as_bytes()
.to_vec();
let partner_credentials = GreenlightCredentials {
developer_cert,
developer_key,
};
let mut breez_config = BreezServices::default_config(
EnvironmentType::Production,
config.breez_sdk_config.breez_sdk_api_key.clone(),
NodeConfig::Greenlight {
config: GreenlightNodeConfig {
partner_credentials: Some(partner_credentials),
invite_code: None,
},
},
);
breez_config
.working_dir
.clone_from(&config.local_persistence_path);
breez_config.exemptfee_msat = config
.max_routing_fee_config
.max_routing_fee_exempt_fee_sats
.as_sats()
.msats;
breez_config.maxfee_percent =
Permyriad(config.max_routing_fee_config.max_routing_fee_permyriad).to_percentage();
let connect_request = ConnectRequest {
config: breez_config,
seed: config.seed.clone(),
restore_only: None,
};
BreezServices::connect(connect_request, event_listener)
.await
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to initialize a breez sdk instance",
)
}
pub fn accept_terms_and_conditions(
backend_url: String,
seed: Vec<u8>,
version: i64,
fingerprint: String,
) -> Result<()> {
enable_backtrace();
let seed = sanitize_input::strong_type_seed(&seed)?;
let auth = build_auth(&seed, &backend_url)?;
auth.accept_terms_and_conditions(TermsAndConditions::Lipa, version, fingerprint)
.map_runtime_error_to(RuntimeErrorCode::AuthServiceUnavailable)
}
pub fn parse_lightning_address(address: &str) -> std::result::Result<(), ParseError> {
parser::parse_lightning_address(address).map_err(ParseError::from)
}
pub fn get_terms_and_conditions_status(
backend_url: String,
seed: Vec<u8>,
terms_and_conditions: TermsAndConditions,
) -> Result<TermsAndConditionsStatus> {
enable_backtrace();
let seed = sanitize_input::strong_type_seed(&seed)?;
let auth = build_auth(&seed, &backend_url)?;
auth.get_terms_and_conditions_status(terms_and_conditions)
.map_runtime_error_to(RuntimeErrorCode::AuthServiceUnavailable)
}
fn get_payment_uuid(payment_hash: String) -> Result<String> {
let hash = hex::decode(payment_hash).map_to_invalid_input("Invalid payment hash encoding")?;
Ok(Uuid::new_v5(&Uuid::NAMESPACE_OID, &hash)
.hyphenated()
.to_string())
}
pub(crate) fn enable_backtrace() {
env::set_var("RUST_BACKTRACE", "1");
}
fn get_payment_max_routing_fee_mode(
config: &MaxRoutingFeeConfig,
amount_sat: u64,
exchange_rate: &Option<ExchangeRate>,
) -> MaxRoutingFeeMode {
let max_fee_permyriad = Permyriad(config.max_routing_fee_permyriad);
let relative_fee = max_fee_permyriad.of(&amount_sat.as_sats());
if relative_fee.msats < config.max_routing_fee_exempt_fee_sats.as_sats().msats {
MaxRoutingFeeMode::Absolute {
max_fee_amount: config
.max_routing_fee_exempt_fee_sats
.as_sats()
.to_amount_up(exchange_rate),
}
} else {
MaxRoutingFeeMode::Relative {
max_fee_permyriad: max_fee_permyriad.0,
}
}
}
fn filter_out_recently_claimed_topups(
topups: Vec<TopupInfo>,
latest_activities: Vec<Activity>,
) -> Vec<TopupInfo> {
let pocket_id = |a: Activity| match a {
Activity::OfferClaim {
incoming_payment_info: _,
offer_kind: OfferKind::Pocket { id, .. },
} => Some(id),
_ => None,
};
let latest_succeeded_payment_offer_ids: HashSet<String> = latest_activities
.into_iter()
.filter(|a| a.get_payment_info().map(|p| p.payment_state) == Some(PaymentState::Succeeded))
.filter_map(pocket_id)
.collect();
topups
.into_iter()
.filter(|o| !latest_succeeded_payment_offer_ids.contains(&o.id))
.collect()
}
fn fill_payout_fee(
offer: OfferKind,
requested_amount: Msats,
rate: &Option<ExchangeRate>,
) -> OfferKind {
match offer {
OfferKind::Pocket {
id,
exchange_rate,
topup_value_minor_units,
topup_value_sats,
exchange_fee_minor_units,
exchange_fee_rate_permyriad,
lightning_payout_fee: _,
error,
} => {
let lightning_payout_fee = topup_value_sats.map(|v| {
(v.as_sats().msats - requested_amount.msats)
.as_msats()
.to_amount_up(rate)
});
OfferKind::Pocket {
id,
exchange_rate,
topup_value_minor_units,
topup_value_sats,
exchange_fee_minor_units,
exchange_fee_rate_permyriad,
lightning_payout_fee,
error,
}
}
}
}
fn filter_out_and_log_corrupted_activities(r: Result<Activity>) -> Option<Activity> {
if r.is_ok() {
r.ok()
} else {
error!(
"Corrupted activity data, ignoring activity: {}",
r.expect_err("Expected error, received ok")
);
None
}
}
fn filter_out_and_log_corrupted_payments(
r: Result<IncomingPaymentInfo>,
) -> Option<IncomingPaymentInfo> {
if r.is_ok() {
r.ok()
} else {
error!(
"Corrupted payment data, ignoring payment: {}",
r.expect_err("Expected error, received ok")
);
None
}
}
pub(crate) fn register_webhook_url(
rt: &AsyncRuntime,
sdk: &BreezServices,
auth: &Auth,
config: &Config,
) -> Result<()> {
let id = auth.get_wallet_pubkey_id().map_to_runtime_error(
RuntimeErrorCode::AuthServiceUnavailable,
"Failed to authenticate in order to get wallet pubkey id",
)?;
let encrypted_id = deterministic_encrypt(
id.as_bytes(),
&<[u8; 32]>::from_hex(
&config
.remote_services_config
.notification_webhook_secret_hex,
)
.map_to_invalid_input("Invalid notification_webhook_secret_hex")?,
)
.map_to_permanent_failure("Failed to encrypt wallet pubkey id")?;
let encrypted_id = hex::encode(encrypted_id);
let webhook_url = config
.remote_services_config
.notification_webhook_base_url
.replacen("{id}", &encrypted_id, 1);
rt.handle()
.block_on(sdk.register_webhook(webhook_url.clone()))
.map_to_runtime_error(
RuntimeErrorCode::NodeUnavailable,
"Failed to register notification webhook",
)?;
debug!("Successfully registered notification webhook with Breez SDK. URL: {webhook_url}");
Ok(())
}
fn with_status(status: EnableStatus) -> impl Fn((String, EnableStatus)) -> Option<String> {
move |(v, s)| if s == status { Some(v) } else { None }
}
include!(concat!(env!("OUT_DIR"), "/lipalightninglib.uniffi.rs"));
#[cfg(test)]
mod tests {
use super::*;
use crate::amount::Sats;
use crow::TopupStatus;
use perro::Error;
const PAYMENT_HASH: &str = "0b78877a596f18d5f6effde3dda1df25a5cf20439ff1ac91478d7e518211040f";
const PAYMENT_UUID: &str = "c6e597bd-0a98-5b46-8e74-f6098f5d16a3";
#[test]
fn test_payment_uuid() {
let payment_uuid = get_payment_uuid(PAYMENT_HASH.to_string());
assert_eq!(payment_uuid, Ok(PAYMENT_UUID.to_string()));
}
#[test]
fn test_payment_uuid_invalid_input() {
let invalid_hash_encoding = get_payment_uuid("INVALID_HEX_STRING".to_string());
assert!(matches!(
invalid_hash_encoding,
Err(Error::InvalidInput { .. })
));
assert_eq!(
&invalid_hash_encoding.unwrap_err().to_string()[0..43],
"InvalidInput: Invalid payment hash encoding"
);
}
const MAX_FEE_PERMYRIAD: Permyriad = Permyriad(150);
const EXEMPT_FEE: Sats = Sats::new(21);
#[test]
fn test_get_payment_max_routing_fee_mode_absolute() {
let max_routing_mode = get_payment_max_routing_fee_mode(
&MaxRoutingFeeConfig {
max_routing_fee_permyriad: MAX_FEE_PERMYRIAD.0,
max_routing_fee_exempt_fee_sats: EXEMPT_FEE.sats,
},
EXEMPT_FEE.msats / ((MAX_FEE_PERMYRIAD.0 as u64) / 10) - 1,
&None,
);
match max_routing_mode {
MaxRoutingFeeMode::Absolute { max_fee_amount } => {
assert_eq!(max_fee_amount.sats, EXEMPT_FEE.sats);
}
_ => {
panic!("Unexpected variant");
}
}
}
#[test]
fn test_get_payment_max_routing_fee_mode_relative() {
let max_routing_mode = get_payment_max_routing_fee_mode(
&MaxRoutingFeeConfig {
max_routing_fee_permyriad: MAX_FEE_PERMYRIAD.0,
max_routing_fee_exempt_fee_sats: EXEMPT_FEE.sats,
},
EXEMPT_FEE.msats / ((MAX_FEE_PERMYRIAD.0 as u64) / 10),
&None,
);
match max_routing_mode {
MaxRoutingFeeMode::Relative { max_fee_permyriad } => {
assert_eq!(max_fee_permyriad, MAX_FEE_PERMYRIAD.0);
}
_ => {
panic!("Unexpected variant");
}
}
}
#[test]
fn test_filter_out_recently_claimed_topups() {
let topups = vec![
TopupInfo {
id: "123".to_string(),
status: TopupStatus::READY,
amount_sat: 0,
topup_value_minor_units: 0,
exchange_fee_rate_permyriad: 0,
exchange_fee_minor_units: 0,
exchange_rate: graphql::ExchangeRate {
currency_code: "eur".to_string(),
sats_per_unit: 0,
updated_at: SystemTime::now(),
},
expires_at: None,
lnurlw: None,
error: None,
},
TopupInfo {
id: "234".to_string(),
status: TopupStatus::READY,
amount_sat: 0,
topup_value_minor_units: 0,
exchange_fee_rate_permyriad: 0,
exchange_fee_minor_units: 0,
exchange_rate: graphql::ExchangeRate {
currency_code: "eur".to_string(),
sats_per_unit: 0,
updated_at: SystemTime::now(),
},
expires_at: None,
lnurlw: None,
error: None,
},
];
let mut payment_info = PaymentInfo {
payment_state: PaymentState::Succeeded,
hash: "hash".to_string(),
amount: Amount::default(),
invoice_details: InvoiceDetails {
invoice: "bca".to_string(),
amount: None,
description: "".to_string(),
payment_hash: "".to_string(),
payee_pub_key: "".to_string(),
creation_timestamp: SystemTime::now(),
expiry_interval: Default::default(),
expiry_timestamp: SystemTime::now(),
},
created_at: SystemTime::now().with_timezone(TzConfig::default()),
description: "".to_string(),
preimage: None,
personal_note: None,
};
let incoming_payment = Activity::IncomingPayment {
incoming_payment_info: IncomingPaymentInfo {
payment_info: payment_info.clone(),
requested_amount: Amount::default(),
lsp_fees: Amount::default(),
received_on: None,
received_lnurl_comment: None,
},
};
payment_info.hash = "hash2".to_string();
let topup = Activity::OfferClaim {
incoming_payment_info: IncomingPaymentInfo {
payment_info: payment_info.clone(),
requested_amount: Amount::default(),
lsp_fees: Amount::default(),
received_on: None,
received_lnurl_comment: None,
},
offer_kind: OfferKind::Pocket {
id: "123".to_string(),
exchange_rate: ExchangeRate {
currency_code: "".to_string(),
rate: 0,
updated_at: SystemTime::now(),
},
topup_value_minor_units: 0,
topup_value_sats: Some(0),
exchange_fee_minor_units: 0,
exchange_fee_rate_permyriad: 0,
lightning_payout_fee: None,
error: None,
},
};
payment_info.hash = "hash3".to_string();
payment_info.payment_state = PaymentState::Failed;
let failed_topup = Activity::OfferClaim {
incoming_payment_info: IncomingPaymentInfo {
payment_info,
requested_amount: Amount::default(),
lsp_fees: Amount::default(),
received_on: None,
received_lnurl_comment: None,
},
offer_kind: OfferKind::Pocket {
id: "234".to_string(),
exchange_rate: ExchangeRate {
currency_code: "".to_string(),
rate: 0,
updated_at: SystemTime::now(),
},
topup_value_minor_units: 0,
topup_value_sats: Some(0),
exchange_fee_minor_units: 0,
exchange_fee_rate_permyriad: 0,
lightning_payout_fee: None,
error: None,
},
};
let latest_payments = vec![incoming_payment, topup, failed_topup];
let filtered_topups = filter_out_recently_claimed_topups(topups, latest_payments);
assert_eq!(filtered_topups.len(), 1);
assert_eq!(filtered_topups.first().unwrap().id, "234");
}
}