diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 0a6495d..9c47fe4 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -43,6 +43,10 @@ pub(crate) enum Command { #[clap(long = "asset")] asset_id: Option, + /// Whether or not the tx should be paid using the asset + #[clap(long, action = ArgAction::SetTrue)] + use_asset_fees: Option, + /// The amount to pay, in case of a Liquid payment. The amount is optional if it is already /// provided in the BIP21 URI. /// The asset id must also be provided. @@ -384,6 +388,7 @@ pub(crate) async fn handle_command( amount, amount_sat, asset_id, + use_asset_fees, drain, delay, } => { @@ -409,6 +414,7 @@ pub(crate) async fn handle_command( (Some(asset_id), Some(receiver_amount), _, _) => Some(PayAmount::Asset { asset_id, receiver_amount, + estimate_asset_fees: use_asset_fees, }), (None, None, Some(receiver_amount_sat), _) => Some(PayAmount::Bitcoin { receiver_amount_sat, @@ -424,16 +430,30 @@ pub(crate) async fn handle_command( }) .await?; - wait_confirmation!( - format!( - "Fees: {} sat. Are the fees acceptable? (y/N) ", - prepare_response.fees_sat - ), - "Payment send halted" - ); + let confirmation_msg = match ( + use_asset_fees.unwrap_or(false), + prepare_response.fees_sat, + prepare_response.estimated_asset_fees, + ) { + (true, _, Some(asset_fees)) => { + format!("Fees: approx {asset_fees}. Are the fees acceptable? (y/N) ") + } + (false, Some(fees_sat), _) => { + format!("Fees: {fees_sat} sat. Are the fees acceptable? (y/N) ") + } + (true, _, None) => { + bail!("Not able to pay asset fees") + } + (false, None, _) => { + bail!("Not able to pay satoshi fees") + } + }; + + wait_confirmation!(confirmation_msg, "Payment send halted"); let send_payment_req = SendPaymentRequest { prepare_response: prepare_response.clone(), + use_asset_fees, }; if let Some(delay) = delay { diff --git a/lib/bindings/langs/flutter/breez_sdk_liquid/include/breez_sdk_liquid.h b/lib/bindings/langs/flutter/breez_sdk_liquid/include/breez_sdk_liquid.h index 0c4caa7..ab0546f 100644 --- a/lib/bindings/langs/flutter/breez_sdk_liquid/include/breez_sdk_liquid.h +++ b/lib/bindings/langs/flutter/breez_sdk_liquid/include/breez_sdk_liquid.h @@ -22,6 +22,16 @@ typedef struct _Dart_Handle* Dart_Handle; #define LIQUID_FEE_RATE_MSAT_PER_VBYTE (float)(LIQUID_FEE_RATE_SAT_PER_VBYTE * 1000.0) +#define MIN_FEE_RATE 0.1 + +#define WEIGHT_FIXED 222 + +#define WEIGHT_VIN_SINGLE_SIG_NATIVE 275 + +#define WEIGHT_VIN_SINGLE_SIG_NESTED 367 + +#define WEIGHT_VOUT_NESTED 270 + /** * The maximum acceptable amount in satoshi when claiming using zero-conf */ @@ -358,6 +368,7 @@ typedef struct wire_cst_PayAmount_Bitcoin { typedef struct wire_cst_PayAmount_Asset { struct wire_cst_list_prim_u_8_strict *asset_id; double receiver_amount; + bool *estimate_asset_fees; } wire_cst_PayAmount_Asset; typedef union PayAmountKind { @@ -445,11 +456,13 @@ typedef struct wire_cst_restore_request { typedef struct wire_cst_prepare_send_response { struct wire_cst_send_destination destination; - uint64_t fees_sat; + uint64_t *fees_sat; + double *estimated_asset_fees; } wire_cst_prepare_send_response; typedef struct wire_cst_send_payment_request { struct wire_cst_prepare_send_response prepare_response; + bool *use_asset_fees; } wire_cst_send_payment_request; typedef struct wire_cst_sign_message_request { @@ -536,6 +549,7 @@ typedef struct wire_cst_asset_info { struct wire_cst_list_prim_u_8_strict *name; struct wire_cst_list_prim_u_8_strict *ticker; double amount; + double *fees; } wire_cst_asset_info; typedef struct wire_cst_PaymentDetails_Liquid { @@ -670,6 +684,7 @@ typedef struct wire_cst_asset_metadata { struct wire_cst_list_prim_u_8_strict *name; struct wire_cst_list_prim_u_8_strict *ticker; uint8_t precision; + struct wire_cst_list_prim_u_8_strict *fiat_id; } wire_cst_asset_metadata; typedef struct wire_cst_list_asset_metadata { @@ -691,6 +706,7 @@ typedef struct wire_cst_config { bool use_default_external_input_parsers; uint32_t *onchain_fee_rate_leeway_sat_per_vbyte; struct wire_cst_list_asset_metadata *asset_metadata; + struct wire_cst_list_prim_u_8_strict *sideswap_api_key; } wire_cst_config; typedef struct wire_cst_connect_request { diff --git a/lib/bindings/src/breez_sdk_liquid.udl b/lib/bindings/src/breez_sdk_liquid.udl index af21def..6a213a5 100644 --- a/lib/bindings/src/breez_sdk_liquid.udl +++ b/lib/bindings/src/breez_sdk_liquid.udl @@ -345,6 +345,7 @@ dictionary Config { sequence? external_input_parsers = null; u32? onchain_fee_rate_leeway_sat_per_vbyte = null; sequence? asset_metadata = null; + string? sideswap_api_key = null; }; enum LiquidNetwork { @@ -443,11 +444,13 @@ interface SendDestination { dictionary PrepareSendResponse { SendDestination destination; - u64 fees_sat; + u64? fees_sat; + f64? estimated_asset_fees; }; dictionary SendPaymentRequest { PrepareSendResponse prepare_response; + boolean? use_asset_fees = null; }; dictionary SendPaymentResponse { @@ -509,7 +512,7 @@ dictionary OnchainPaymentLimitsResponse { [Enum] interface PayAmount { Bitcoin(u64 receiver_amount_sat); - Asset(string asset_id, f64 receiver_amount); + Asset(string asset_id, f64 receiver_amount, boolean? estimate_asset_fees); Drain(); }; @@ -609,6 +612,7 @@ dictionary AssetInfo { string name; string ticker; f64 amount; + f64? fees; }; [Enum] @@ -722,6 +726,7 @@ dictionary AssetMetadata { string name; string ticker; u8 precision; + string? fiat_id = null; }; namespace breez_sdk_liquid { diff --git a/lib/core/src/error.rs b/lib/core/src/error.rs index c68d9f6..3e5ce50 100644 --- a/lib/core/src/error.rs +++ b/lib/core/src/error.rs @@ -2,6 +2,8 @@ use anyhow::Error; use lwk_wollet::secp256k1; use sdk_common::prelude::{LnUrlAuthError, LnUrlPayError, LnUrlWithdrawError}; +use crate::payjoin::error::PayjoinError; + pub type SdkResult = Result; #[macro_export] @@ -219,6 +221,17 @@ impl From for PaymentError { } } +impl From for PaymentError { + fn from(err: PayjoinError) -> Self { + match err { + PayjoinError::InsufficientFunds => PaymentError::InsufficientFunds, + _ => PaymentError::Generic { + err: format!("{err:?}"), + }, + } + } +} + impl From for PaymentError { fn from(_: rusqlite::Error) -> Self { Self::PersistError diff --git a/lib/core/src/frb_generated.rs b/lib/core/src/frb_generated.rs index 3de016c..c4135cd 100644 --- a/lib/core/src/frb_generated.rs +++ b/lib/core/src/frb_generated.rs @@ -2413,10 +2413,12 @@ impl SseDecode for crate::model::AssetInfo { let mut var_name = ::sse_decode(deserializer); let mut var_ticker = ::sse_decode(deserializer); let mut var_amount = ::sse_decode(deserializer); + let mut var_fees = >::sse_decode(deserializer); return crate::model::AssetInfo { name: var_name, ticker: var_ticker, amount: var_amount, + fees: var_fees, }; } } @@ -2428,11 +2430,13 @@ impl SseDecode for crate::model::AssetMetadata { let mut var_name = ::sse_decode(deserializer); let mut var_ticker = ::sse_decode(deserializer); let mut var_precision = ::sse_decode(deserializer); + let mut var_fiatId = >::sse_decode(deserializer); return crate::model::AssetMetadata { asset_id: var_assetId, name: var_name, ticker: var_ticker, precision: var_precision, + fiat_id: var_fiatId, }; } } @@ -2585,6 +2589,7 @@ impl SseDecode for crate::model::Config { let mut var_onchainFeeRateLeewaySatPerVbyte = >::sse_decode(deserializer); let mut var_assetMetadata = >>::sse_decode(deserializer); + let mut var_sideswapApiKey = >::sse_decode(deserializer); return crate::model::Config { liquid_explorer: var_liquidExplorer, bitcoin_explorer: var_bitcoinExplorer, @@ -2599,6 +2604,7 @@ impl SseDecode for crate::model::Config { use_default_external_input_parsers: var_useDefaultExternalInputParsers, onchain_fee_rate_leeway_sat_per_vbyte: var_onchainFeeRateLeewaySatPerVbyte, asset_metadata: var_assetMetadata, + sideswap_api_key: var_sideswapApiKey, }; } } @@ -3885,9 +3891,11 @@ impl SseDecode for crate::model::PayAmount { 1 => { let mut var_assetId = ::sse_decode(deserializer); let mut var_receiverAmount = ::sse_decode(deserializer); + let mut var_estimateAssetFees = >::sse_decode(deserializer); return crate::model::PayAmount::Asset { asset_id: var_assetId, receiver_amount: var_receiverAmount, + estimate_asset_fees: var_estimateAssetFees, }; } 2 => { @@ -4316,10 +4324,12 @@ impl SseDecode for crate::model::PrepareSendResponse { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut var_destination = ::sse_decode(deserializer); - let mut var_feesSat = ::sse_decode(deserializer); + let mut var_feesSat = >::sse_decode(deserializer); + let mut var_estimatedAssetFees = >::sse_decode(deserializer); return crate::model::PrepareSendResponse { destination: var_destination, fees_sat: var_feesSat, + estimated_asset_fees: var_estimatedAssetFees, }; } } @@ -4623,8 +4633,10 @@ impl SseDecode for crate::model::SendPaymentRequest { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut var_prepareResponse = ::sse_decode(deserializer); + let mut var_useAssetFees = >::sse_decode(deserializer); return crate::model::SendPaymentRequest { prepare_response: var_prepareResponse, + use_asset_fees: var_useAssetFees, }; } } @@ -4991,6 +5003,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::AssetInfo { self.name.into_into_dart().into_dart(), self.ticker.into_into_dart().into_dart(), self.amount.into_into_dart().into_dart(), + self.fees.into_into_dart().into_dart(), ] .into_dart() } @@ -5009,6 +5022,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::AssetMetadata { self.name.into_into_dart().into_dart(), self.ticker.into_into_dart().into_dart(), self.precision.into_into_dart().into_dart(), + self.fiat_id.into_into_dart().into_dart(), ] .into_dart() } @@ -5228,6 +5242,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::Config { .into_into_dart() .into_dart(), self.asset_metadata.into_into_dart().into_dart(), + self.sideswap_api_key.into_into_dart().into_dart(), ] .into_dart() } @@ -6246,10 +6261,12 @@ impl flutter_rust_bridge::IntoDart for crate::model::PayAmount { crate::model::PayAmount::Asset { asset_id, receiver_amount, + estimate_asset_fees, } => [ 1.into_dart(), asset_id.into_into_dart().into_dart(), receiver_amount.into_into_dart().into_dart(), + estimate_asset_fees.into_into_dart().into_dart(), ] .into_dart(), crate::model::PayAmount::Drain => [2.into_dart()].into_dart(), @@ -6767,6 +6784,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::PrepareSendResponse { [ self.destination.into_into_dart().into_dart(), self.fees_sat.into_into_dart().into_dart(), + self.estimated_asset_fees.into_into_dart().into_dart(), ] .into_dart() } @@ -7128,7 +7146,11 @@ impl flutter_rust_bridge::IntoIntoDart // Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::model::SendPaymentRequest { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { - [self.prepare_response.into_into_dart().into_dart()].into_dart() + [ + self.prepare_response.into_into_dart().into_dart(), + self.use_asset_fees.into_into_dart().into_dart(), + ] + .into_dart() } } impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive @@ -7452,6 +7474,7 @@ impl SseEncode for crate::model::AssetInfo { ::sse_encode(self.name, serializer); ::sse_encode(self.ticker, serializer); ::sse_encode(self.amount, serializer); + >::sse_encode(self.fees, serializer); } } @@ -7462,6 +7485,7 @@ impl SseEncode for crate::model::AssetMetadata { ::sse_encode(self.name, serializer); ::sse_encode(self.ticker, serializer); ::sse_encode(self.precision, serializer); + >::sse_encode(self.fiat_id, serializer); } } @@ -7586,6 +7610,7 @@ impl SseEncode for crate::model::Config { ::sse_encode(self.use_default_external_input_parsers, serializer); >::sse_encode(self.onchain_fee_rate_leeway_sat_per_vbyte, serializer); >>::sse_encode(self.asset_metadata, serializer); + >::sse_encode(self.sideswap_api_key, serializer); } } @@ -8605,10 +8630,12 @@ impl SseEncode for crate::model::PayAmount { crate::model::PayAmount::Asset { asset_id, receiver_amount, + estimate_asset_fees, } => { ::sse_encode(1, serializer); ::sse_encode(asset_id, serializer); ::sse_encode(receiver_amount, serializer); + >::sse_encode(estimate_asset_fees, serializer); } crate::model::PayAmount::Drain => { ::sse_encode(2, serializer); @@ -8967,7 +8994,8 @@ impl SseEncode for crate::model::PrepareSendResponse { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { ::sse_encode(self.destination, serializer); - ::sse_encode(self.fees_sat, serializer); + >::sse_encode(self.fees_sat, serializer); + >::sse_encode(self.estimated_asset_fees, serializer); } } @@ -9200,6 +9228,7 @@ impl SseEncode for crate::model::SendPaymentRequest { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { ::sse_encode(self.prepare_response, serializer); + >::sse_encode(self.use_asset_fees, serializer); } } @@ -9525,6 +9554,7 @@ mod io { name: self.name.cst_decode(), ticker: self.ticker.cst_decode(), amount: self.amount.cst_decode(), + fees: self.fees.cst_decode(), } } } @@ -9536,6 +9566,7 @@ mod io { name: self.name.cst_decode(), ticker: self.ticker.cst_decode(), precision: self.precision.cst_decode(), + fiat_id: self.fiat_id.cst_decode(), } } } @@ -10044,6 +10075,7 @@ mod io { .onchain_fee_rate_leeway_sat_per_vbyte .cst_decode(), asset_metadata: self.asset_metadata.cst_decode(), + sideswap_api_key: self.sideswap_api_key.cst_decode(), } } } @@ -10878,6 +10910,7 @@ mod io { crate::model::PayAmount::Asset { asset_id: ans.asset_id.cst_decode(), receiver_amount: ans.receiver_amount.cst_decode(), + estimate_asset_fees: ans.estimate_asset_fees.cst_decode(), } } 2 => crate::model::PayAmount::Drain, @@ -11166,6 +11199,7 @@ mod io { crate::model::PrepareSendResponse { destination: self.destination.cst_decode(), fees_sat: self.fees_sat.cst_decode(), + estimated_asset_fees: self.estimated_asset_fees.cst_decode(), } } } @@ -11408,6 +11442,7 @@ mod io { fn cst_decode(self) -> crate::model::SendPaymentRequest { crate::model::SendPaymentRequest { prepare_response: self.prepare_response.cst_decode(), + use_asset_fees: self.use_asset_fees.cst_decode(), } } } @@ -11608,6 +11643,7 @@ mod io { name: core::ptr::null_mut(), ticker: core::ptr::null_mut(), amount: Default::default(), + fees: core::ptr::null_mut(), } } } @@ -11623,6 +11659,7 @@ mod io { name: core::ptr::null_mut(), ticker: core::ptr::null_mut(), precision: Default::default(), + fiat_id: core::ptr::null_mut(), } } } @@ -11752,6 +11789,7 @@ mod io { use_default_external_input_parsers: Default::default(), onchain_fee_rate_leeway_sat_per_vbyte: core::ptr::null_mut(), asset_metadata: core::ptr::null_mut(), + sideswap_api_key: core::ptr::null_mut(), } } } @@ -12539,7 +12577,8 @@ mod io { fn new_with_null_ptr() -> Self { Self { destination: Default::default(), - fees_sat: Default::default(), + fees_sat: core::ptr::null_mut(), + estimated_asset_fees: core::ptr::null_mut(), } } } @@ -12742,6 +12781,7 @@ mod io { fn new_with_null_ptr() -> Self { Self { prepare_response: Default::default(), + use_asset_fees: core::ptr::null_mut(), } } } @@ -13937,6 +13977,7 @@ mod io { name: *mut wire_cst_list_prim_u_8_strict, ticker: *mut wire_cst_list_prim_u_8_strict, amount: f64, + fees: *mut f64, } #[repr(C)] #[derive(Clone, Copy)] @@ -13945,6 +13986,7 @@ mod io { name: *mut wire_cst_list_prim_u_8_strict, ticker: *mut wire_cst_list_prim_u_8_strict, precision: u8, + fiat_id: *mut wire_cst_list_prim_u_8_strict, } #[repr(C)] #[derive(Clone, Copy)] @@ -14029,6 +14071,7 @@ mod io { use_default_external_input_parsers: bool, onchain_fee_rate_leeway_sat_per_vbyte: *mut u32, asset_metadata: *mut wire_cst_list_asset_metadata, + sideswap_api_key: *mut wire_cst_list_prim_u_8_strict, } #[repr(C)] #[derive(Clone, Copy)] @@ -14722,6 +14765,7 @@ mod io { pub struct wire_cst_PayAmount_Asset { asset_id: *mut wire_cst_list_prim_u_8_strict, receiver_amount: f64, + estimate_asset_fees: *mut bool, } #[repr(C)] #[derive(Clone, Copy)] @@ -14952,7 +14996,8 @@ mod io { #[derive(Clone, Copy)] pub struct wire_cst_prepare_send_response { destination: wire_cst_send_destination, - fees_sat: u64, + fees_sat: *mut u64, + estimated_asset_fees: *mut f64, } #[repr(C)] #[derive(Clone, Copy)] @@ -15171,6 +15216,7 @@ mod io { #[derive(Clone, Copy)] pub struct wire_cst_send_payment_request { prepare_response: wire_cst_prepare_send_response, + use_asset_fees: *mut bool, } #[repr(C)] #[derive(Clone, Copy)] diff --git a/lib/core/src/lib.rs b/lib/core/src/lib.rs index 416b8e8..97d165f 100644 --- a/lib/core/src/lib.rs +++ b/lib/core/src/lib.rs @@ -177,6 +177,7 @@ pub(crate) mod lnurl; #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub mod logger; pub mod model; +pub(crate) mod payjoin; pub mod persist; pub mod receive_swap; pub(crate) mod recover; diff --git a/lib/core/src/model.rs b/lib/core/src/model.rs index 873821e..9cbc01b 100644 --- a/lib/core/src/model.rs +++ b/lib/core/src/model.rs @@ -41,6 +41,8 @@ pub const LIQUID_FEE_RATE_SAT_PER_VBYTE: f64 = 0.1; pub const LIQUID_FEE_RATE_MSAT_PER_VBYTE: f32 = (LIQUID_FEE_RATE_SAT_PER_VBYTE * 1000.0) as f32; pub const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology"; +const SIDESWAP_API_KEY: &str = "97fb6a1dfa37ee6656af92ef79675cc03b8ac4c52e04655f41edbd5af888dcc2"; + #[derive(Clone, Debug, Serialize)] pub enum BlockchainExplorer { #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] @@ -94,6 +96,8 @@ pub struct Config { /// See [AssetMetadata] for more details on how define asset metadata. /// By default the asset metadata for Liquid Bitcoin and Tether USD are included. pub asset_metadata: Option>, + /// The SideSwap API key used for making requests to the SideSwap payjoin service + pub sideswap_api_key: Option, } impl Config { @@ -117,6 +121,7 @@ impl Config { use_default_external_input_parsers: true, onchain_fee_rate_leeway_sat_per_vbyte: None, asset_metadata: None, + sideswap_api_key: Some(SIDESWAP_API_KEY.to_string()), } } @@ -141,6 +146,7 @@ impl Config { use_default_external_input_parsers: true, onchain_fee_rate_leeway_sat_per_vbyte: None, asset_metadata: None, + sideswap_api_key: Some(SIDESWAP_API_KEY.to_string()), } } @@ -164,6 +170,7 @@ impl Config { use_default_external_input_parsers: true, onchain_fee_rate_leeway_sat_per_vbyte: None, asset_metadata: None, + sideswap_api_key: Some(SIDESWAP_API_KEY.to_string()), } } @@ -188,6 +195,7 @@ impl Config { use_default_external_input_parsers: true, onchain_fee_rate_leeway_sat_per_vbyte: None, asset_metadata: None, + sideswap_api_key: Some(SIDESWAP_API_KEY.to_string()), } } @@ -211,6 +219,7 @@ impl Config { use_default_external_input_parsers: true, onchain_fee_rate_leeway_sat_per_vbyte: None, asset_metadata: None, + sideswap_api_key: None, } } @@ -235,6 +244,7 @@ impl Config { use_default_external_input_parsers: true, onchain_fee_rate_leeway_sat_per_vbyte: None, asset_metadata: None, + sideswap_api_key: None, } } @@ -344,7 +354,7 @@ impl Config { /// Network chosen for this Liquid SDK instance. Note that it represents both the Liquid and the /// Bitcoin network used. -#[derive(Debug, Copy, Clone, PartialEq, Serialize)] +#[derive(Debug, Display, Copy, Clone, PartialEq, Serialize)] pub enum LiquidNetwork { /// Mainnet Bitcoin and Liquid chains Mainnet, @@ -707,13 +717,20 @@ pub enum SendDestination { #[derive(Debug, Serialize, Clone)] pub struct PrepareSendResponse { pub destination: SendDestination, - pub fees_sat: u64, + /// The optional estimated fee in satoshi. Is set when there is Bitcoin available + /// to pay fees. When not set, there are asset fees available to pay fees. + pub fees_sat: Option, + /// The optional estimated fee in the asset. Is set when [PayAmount::Asset::estimate_asset_fees] + /// is set to `true`, the Payjoin service accepts this asset to pay fees and there + /// are funds available in this asset to pay fees. + pub estimated_asset_fees: Option, } /// An argument when calling [crate::sdk::LiquidSdk::send_payment]. #[derive(Debug, Serialize)] pub struct SendPaymentRequest { pub prepare_response: PrepareSendResponse, + pub use_asset_fees: Option, } /// Returned when calling [crate::sdk::LiquidSdk::send_payment]. @@ -732,6 +749,7 @@ pub enum PayAmount { Asset { asset_id: String, receiver_amount: f64, + estimate_asset_fees: Option, }, /// Indicates that all available Bitcoin funds should be sent @@ -837,9 +855,10 @@ impl WalletInfo { &self, network: LiquidNetwork, amount_sat: u64, - fees_sat: u64, + fees_sat: Option, asset_id: &str, ) -> Result<(), PaymentError> { + let fees_sat = fees_sat.unwrap_or(0); if asset_id.eq(&utils::lbtc_asset_id(network).to_string()) { ensure_sdk!( amount_sat + fees_sat <= self.balance_sat, @@ -1754,6 +1773,8 @@ pub struct AssetMetadata { /// The precision used to display the asset amount. /// For example, precision of 2 shifts the decimal 2 places left from the satoshi amount. pub precision: u8, + /// The optional ID of the fiat currency used to represent the asset + pub fiat_id: Option, } impl AssetMetadata { @@ -1777,6 +1798,9 @@ pub struct AssetInfo { /// The amount calculated from the satoshi amount of the transaction, having its /// decimal shifted to the left by the [precision](AssetMetadata::precision) pub amount: f64, + /// The optional fees when paid using the asset, having its + /// decimal shifted to the left by the [precision](AssetMetadata::precision) + pub fees: Option, } /// The specific details of a payment, depending on its type @@ -2020,10 +2044,21 @@ impl Payment { s.payer_amount_sat.saturating_sub(s.receiver_amount_sat), ), }, - None => match tx.payment_type { - PaymentType::Receive => (tx.amount, 0), - PaymentType::Send => (tx.amount, tx.fees_sat), - }, + None => { + let (amount_sat, fees_sat) = match tx.payment_type { + PaymentType::Receive => (tx.amount, 0), + PaymentType::Send => (tx.amount, tx.fees_sat), + }; + // If the payment is a Liquid payment, we only show the amount if the asset + // is LBTC and only show the fees if the asset info has no set fees + match details { + PaymentDetails::Liquid { + asset_info: Some(ref asset_info), + .. + } if asset_info.ticker != "BTC" => (0, asset_info.fees.map_or(fees_sat, |_| 0)), + _ => (amount_sat, fees_sat), + } + } }; Payment { tx_id: Some(tx.tx_id), diff --git a/lib/core/src/payjoin/error.rs b/lib/core/src/payjoin/error.rs new file mode 100644 index 0000000..45bdd27 --- /dev/null +++ b/lib/core/src/payjoin/error.rs @@ -0,0 +1,73 @@ +use lwk_wollet::{bitcoin, elements}; +use sdk_common::prelude::ServiceConnectivityError; + +use crate::error::PaymentError; + +pub type PayjoinResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum PayjoinError { + #[error("{0}")] + Generic(String), + + #[error("Cannot pay: not enough funds")] + InsufficientFunds, + + #[error("{0}")] + ServiceConnectivity(String), +} + +impl PayjoinError { + pub(crate) fn generic>(err: S) -> Self { + Self::Generic(err.as_ref().to_string()) + } + + pub(crate) fn service_connectivity>(err: S) -> Self { + Self::ServiceConnectivity(err.as_ref().to_string()) + } +} + +impl From for PayjoinError { + fn from(err: anyhow::Error) -> Self { + Self::Generic(err.to_string()) + } +} + +impl From for PayjoinError { + fn from(err: bitcoin::base64::DecodeError) -> Self { + Self::Generic(err.to_string()) + } +} + +impl From for PayjoinError { + fn from(err: elements::encode::Error) -> Self { + Self::Generic(err.to_string()) + } +} + +impl From for PayjoinError { + fn from(err: elements::hashes::hex::HexToArrayError) -> Self { + Self::Generic(err.to_string()) + } +} + +impl From for PayjoinError { + fn from(value: PaymentError) -> Self { + match value { + PaymentError::InsufficientFunds => Self::InsufficientFunds, + _ => Self::Generic(value.to_string()), + } + } +} + +impl From for PayjoinError { + fn from(value: serde_json::error::Error) -> Self { + Self::Generic(value.to_string()) + } +} + +impl From for PayjoinError { + fn from(value: ServiceConnectivityError) -> Self { + Self::ServiceConnectivity(value.err) + } +} diff --git a/lib/core/src/payjoin/mod.rs b/lib/core/src/payjoin/mod.rs new file mode 100644 index 0000000..78756b8 --- /dev/null +++ b/lib/core/src/payjoin/mod.rs @@ -0,0 +1,29 @@ +pub(crate) mod error; +pub(crate) mod model; +mod network_fee; +mod pset; +pub(crate) mod side_swap; +mod utxo_select; + +use error::PayjoinResult; +use lwk_wollet::elements::Transaction; +use maybe_sync::{MaybeSend, MaybeSync}; +use model::AcceptedAsset; + +#[sdk_macros::async_trait] +pub trait PayjoinService: MaybeSend + MaybeSync { + /// Get a list of accepted assets + async fn fetch_accepted_assets(&self) -> PayjoinResult>; + + /// Estimate the fee for a payjoin transaction + async fn estimate_payjoin_tx_fee(&self, asset_id: &str, amount_sat: u64) -> PayjoinResult; + + /// Build a payjoin transaction to send funds to a recipient using the asset to pay fees. + /// Returns the transaction and the service fee paid in satoshi units. + async fn build_payjoin_tx( + &self, + recipient_address: &str, + asset_id: &str, + amount_sat: u64, + ) -> PayjoinResult<(Transaction, u64)>; +} diff --git a/lib/core/src/payjoin/model.rs b/lib/core/src/payjoin/model.rs new file mode 100644 index 0000000..40a9334 --- /dev/null +++ b/lib/core/src/payjoin/model.rs @@ -0,0 +1,107 @@ +use lwk_wollet::{ + elements::{ + confidential::{AssetBlindingFactor, ValueBlindingFactor}, + script::Script, + Address, AssetId, Txid, + }, + WalletTxOut, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[allow(clippy::large_enum_variant)] +pub(crate) enum Request { + AcceptedAssets(AcceptedAssetsRequest), + Start(StartRequest), + Sign(SignRequest), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[allow(clippy::large_enum_variant)] +pub(crate) enum Response { + AcceptedAssets(AcceptedAssetsResponse), + Start(StartResponse), + Sign(SignResponse), +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct AcceptedAssetsRequest {} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct AcceptedAssetsResponse { + pub accepted_asset: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AcceptedAsset { + pub asset_id: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct Recipient { + pub address: Address, + pub asset_id: AssetId, + pub amount: u64, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) struct InOut { + pub asset_id: AssetId, + pub value: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct Utxo { + pub txid: Txid, + pub vout: u32, + pub script_pub_key: Script, + pub asset_id: AssetId, + pub value: u64, + pub asset_bf: AssetBlindingFactor, + pub value_bf: ValueBlindingFactor, +} + +impl From<&WalletTxOut> for Utxo { + fn from(tx_out: &WalletTxOut) -> Self { + Self { + txid: tx_out.outpoint.txid, + vout: tx_out.outpoint.vout, + script_pub_key: tx_out.script_pubkey.clone(), + asset_id: tx_out.unblinded.asset, + value: tx_out.unblinded.value, + asset_bf: tx_out.unblinded.asset_bf, + value_bf: tx_out.unblinded.value_bf, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct StartRequest { + pub asset_id: String, + pub user_agent: String, + pub api_key: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct StartResponse { + pub order_id: String, + pub expires_at: u64, + pub price: f64, + pub fixed_fee: u64, + pub fee_address: Address, + pub change_address: Address, + pub utxos: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SignRequest { + pub order_id: String, + pub pset: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SignResponse { + pub pset: String, +} diff --git a/lib/core/src/payjoin/network_fee.rs b/lib/core/src/payjoin/network_fee.rs new file mode 100644 index 0000000..6eaa983 --- /dev/null +++ b/lib/core/src/payjoin/network_fee.rs @@ -0,0 +1,113 @@ +pub const MIN_FEE_RATE: f64 = 0.1; + +pub const WEIGHT_FIXED: usize = 222; +pub const WEIGHT_VIN_SINGLE_SIG_NATIVE: usize = 275; +pub const WEIGHT_VIN_SINGLE_SIG_NESTED: usize = 367; +pub const WEIGHT_VOUT_NESTED: usize = 270; + +pub fn weight_to_vsize(weight: usize) -> usize { + (weight + 3) / 4 +} + +pub fn vsize_to_fee(vsize: usize, fee_rate: f64) -> u64 { + (vsize as f64 * fee_rate).ceil() as u64 +} + +pub fn weight_to_fee(weight: usize, fee_rate: f64) -> u64 { + vsize_to_fee(weight_to_vsize(weight), fee_rate) +} + +#[derive(Copy, Clone, Default)] +pub struct TxFee { + pub server_inputs: usize, + pub user_inputs: usize, + pub outputs: usize, +} + +impl TxFee { + pub fn tx_weight(&self) -> usize { + let TxFee { + server_inputs, + user_inputs, + outputs, + } = self; + WEIGHT_FIXED + + WEIGHT_VIN_SINGLE_SIG_NATIVE * server_inputs + + WEIGHT_VIN_SINGLE_SIG_NESTED * user_inputs + + WEIGHT_VOUT_NESTED * outputs + } + + pub fn fee(&self) -> u64 { + weight_to_fee(self.tx_weight(), MIN_FEE_RATE) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[sdk_macros::test_all] + fn test_weight_to_vsize() { + assert_eq!(weight_to_vsize(4), 1); + assert_eq!(weight_to_vsize(5), 2); + assert_eq!(weight_to_vsize(7), 2); + assert_eq!(weight_to_vsize(8), 2); + assert_eq!(weight_to_vsize(9), 3); + assert_eq!(weight_to_vsize(1000), 250); + } + + #[sdk_macros::test_all] + fn test_vsize_to_fee() { + assert_eq!(vsize_to_fee(100, 1.0), 100); + assert_eq!(vsize_to_fee(100, 0.5), 50); + assert_eq!(vsize_to_fee(100, 0.1), 10); + assert_eq!(vsize_to_fee(100, 0.11), 11); + assert_eq!(vsize_to_fee(100, 0.15), 15); + assert_eq!(vsize_to_fee(100, 0.151), 16); + } + + #[sdk_macros::test_all] + fn test_weight_to_fee() { + assert_eq!(weight_to_fee(400, 1.0), 100); + assert_eq!(weight_to_fee(400, 0.5), 50); + assert_eq!(weight_to_fee(401, 1.0), 101); + assert_eq!(weight_to_fee(399, 1.0), 100); + } + + #[sdk_macros::test_all] + fn test_tx_fee_calculation() { + let fee = TxFee { + server_inputs: 1, + user_inputs: 1, + outputs: 2, + }; + + assert_eq!( + fee.tx_weight(), + WEIGHT_FIXED + + WEIGHT_VIN_SINGLE_SIG_NATIVE + + WEIGHT_VIN_SINGLE_SIG_NESTED + + 2 * WEIGHT_VOUT_NESTED + ); + + let empty_fee = TxFee::default(); + assert_eq!(empty_fee.tx_weight(), WEIGHT_FIXED); + assert_eq!(empty_fee.fee(), weight_to_fee(WEIGHT_FIXED, MIN_FEE_RATE)); + + let complex_fee = TxFee { + server_inputs: 3, + user_inputs: 2, + outputs: 4, + }; + assert_eq!( + complex_fee.tx_weight(), + WEIGHT_FIXED + + 3 * WEIGHT_VIN_SINGLE_SIG_NATIVE + + 2 * WEIGHT_VIN_SINGLE_SIG_NESTED + + 4 * WEIGHT_VOUT_NESTED + ); + } +} diff --git a/lib/core/src/payjoin/pset/blind.rs b/lib/core/src/payjoin/pset/blind.rs new file mode 100644 index 0000000..541a75b --- /dev/null +++ b/lib/core/src/payjoin/pset/blind.rs @@ -0,0 +1,270 @@ +use anyhow::{anyhow, Result}; +use bip39::rand; +use lwk_wollet::bitcoin::secp256k1::SecretKey; +use lwk_wollet::elements::pset::Input; +use lwk_wollet::elements::secp256k1_zkp::Generator; +use lwk_wollet::elements::{self, bitcoin, confidential, secp256k1_zkp}; +use lwk_wollet::elements::{ + confidential::{AssetBlindingFactor, ValueBlindingFactor}, + pset::{ + raw::{ProprietaryKey, ProprietaryType}, + PartiallySignedTransaction, + }, + TxOutSecrets, +}; + +const PSET_IN_EXPLICIT_VALUE: ProprietaryType = 0x11; // 8 bytes +const PSET_IN_VALUE_PROOF: ProprietaryType = 0x12; // 73 bytes +const PSET_IN_EXPLICIT_ASSET: ProprietaryType = 0x13; // 2 bytes +const PSET_IN_ASSET_PROOF: ProprietaryType = 0x14; // 67 bytes + +pub fn remove_explicit_values(pset: &mut PartiallySignedTransaction) { + for input in pset.inputs_mut() { + for subtype in [ + PSET_IN_EXPLICIT_VALUE, + PSET_IN_EXPLICIT_ASSET, + PSET_IN_VALUE_PROOF, + PSET_IN_ASSET_PROOF, + ] { + input + .proprietary + .remove(&ProprietaryKey::from_pset_pair(subtype, Vec::new())); + } + } +} + +fn add_input_explicit_proofs(input: &mut Input, secret: &TxOutSecrets) -> Result<()> { + if secret.asset_bf == AssetBlindingFactor::zero() + && secret.value_bf == ValueBlindingFactor::zero() + { + return Ok(()); + } + let secp = secp256k1_zkp::global::SECP256K1; + let mut rng = rand::thread_rng(); + let asset_gen_unblinded = Generator::new_unblinded(secp, secret.asset.into_tag()); + let asset_gen_blinded = input + .witness_utxo + .as_ref() + .ok_or(anyhow!("No witness utxo"))? + .asset + .into_asset_gen(secp) + .ok_or(anyhow!("No asset gen"))?; + + let blind_asset_proof = secp256k1_zkp::SurjectionProof::new( + secp, + &mut rng, + secret.asset.into_tag(), + secret.asset_bf.into_inner(), + &[( + asset_gen_unblinded, + secret.asset.into_tag(), + secp256k1_zkp::ZERO_TWEAK, + )], + )?; + + let blind_value_proof = secp256k1_zkp::RangeProof::new( + secp, + secret.value, + input + .witness_utxo + .as_ref() + .ok_or(anyhow!("No witness utxo"))? + .value + .commitment() + .ok_or(anyhow!("Invalid commitment"))?, + secret.value, + secret.value_bf.into_inner(), + &[], + &[], + secp256k1_zkp::SecretKey::new(&mut rng), + -1, + 0, + asset_gen_blinded, + )?; + + input.proprietary.insert( + ProprietaryKey::from_pset_pair(PSET_IN_EXPLICIT_VALUE, Vec::new()), + elements::encode::serialize(&secret.value), + ); + + input.proprietary.insert( + ProprietaryKey::from_pset_pair(PSET_IN_EXPLICIT_ASSET, Vec::new()), + elements::encode::serialize(&secret.asset), + ); + + let mut blind_value_proof = elements::encode::serialize(&blind_value_proof); + blind_value_proof.remove(0); + let mut blind_asset_proof = elements::encode::serialize(&blind_asset_proof); + blind_asset_proof.remove(0); + + input.proprietary.insert( + ProprietaryKey::from_pset_pair(PSET_IN_VALUE_PROOF, Vec::new()), + blind_value_proof, + ); + + input.proprietary.insert( + ProprietaryKey::from_pset_pair(PSET_IN_ASSET_PROOF, Vec::new()), + blind_asset_proof, + ); + + Ok(()) +} + +pub fn blind_pset( + pset: &mut PartiallySignedTransaction, + inp_txout_sec: &[TxOutSecrets], + blinding_factors: &[(AssetBlindingFactor, ValueBlindingFactor, SecretKey)], +) -> Result<()> { + let secp = secp256k1_zkp::global::SECP256K1; + let rng = &mut rand::thread_rng(); + + for (input, secret) in pset.inputs_mut().iter_mut().zip(inp_txout_sec.iter()) { + add_input_explicit_proofs(input, secret)?; + } + + let mut last_blinded_index = None; + let mut exp_out_secrets = Vec::new(); + + for (index, out) in pset.outputs().iter().enumerate() { + if out.blinding_key.is_none() { + let value = out + .amount + .ok_or(anyhow!("Output {index} value must be set"))?; + exp_out_secrets.push(( + value, + AssetBlindingFactor::zero(), + ValueBlindingFactor::zero(), + )); + } else { + last_blinded_index = Some(index); + } + } + + let last_blinded_index = last_blinded_index.ok_or(anyhow!("No blinding output found"))?; + + let inputs = inp_txout_sec + .iter() + .map(|secret| { + let tag = secret.asset.into_tag(); + let tweak = secret.asset_bf.into_inner(); + let gen = Generator::new_blinded(secp, tag, tweak); + (gen, tag, tweak) + }) + .collect::>(); + + for (index, output) in pset.outputs_mut().iter_mut().enumerate() { + let asset_id = output + .asset + .ok_or(anyhow!("Output {index} asset must be set"))?; + let value = output + .amount + .ok_or(anyhow!("Output {index} value must be set"))?; + if let Some(receiver_blinding_pk) = output.blinding_key { + let is_last = index == last_blinded_index; + let blinding_factor = blinding_factors.get(index); + + let out_abf = if let Some(blinding_factor) = blinding_factor { + blinding_factor.0 + } else { + AssetBlindingFactor::new(rng) + }; + + let out_asset_commitment = + Generator::new_blinded(secp, asset_id.into_tag(), out_abf.into_inner()); + + let out_vbf = if is_last { + let inp_secrets = inp_txout_sec + .iter() + .map(|o| (o.value, o.asset_bf, o.value_bf)) + .collect::>(); + + ValueBlindingFactor::last(secp, value, out_abf, &inp_secrets, &exp_out_secrets) + } else if let Some(blinding_factor) = blinding_factor { + blinding_factor.1 + } else { + ValueBlindingFactor::new(rng) + }; + + let value_commitment = secp256k1_zkp::PedersenCommitment::new( + secp, + value, + out_vbf.into_inner(), + out_asset_commitment, + ); + + let ephemeral_sk = if let Some(blinding_factor) = blinding_factor { + blinding_factor.2 + } else { + SecretKey::new(rng) + }; + + let (nonce, shared_secret) = confidential::Nonce::with_ephemeral_sk( + secp, + ephemeral_sk, + &receiver_blinding_pk.inner, + ); + + let mut message = [0u8; 64]; + message[..32].copy_from_slice(asset_id.into_tag().as_ref()); + message[32..].copy_from_slice(out_abf.into_inner().as_ref()); + + let rangeproof = secp256k1_zkp::RangeProof::new( + secp, + 1, + value_commitment, + value, + out_vbf.into_inner(), + &message, + output.script_pubkey.as_bytes(), + shared_secret, + 0, + 52, + out_asset_commitment, + )?; + + let surjection_proof = secp256k1_zkp::SurjectionProof::new( + secp, + rng, + asset_id.into_tag(), + out_abf.into_inner(), + &inputs, + )?; + + output.value_rangeproof = Some(Box::new(rangeproof)); + output.asset_surjection_proof = Some(Box::new(surjection_proof)); + output.amount_comm = Some(value_commitment); + output.asset_comm = Some(out_asset_commitment); + output.ecdh_pubkey = nonce.commitment().map(|pk| bitcoin::PublicKey { + inner: pk, + compressed: true, + }); + + let gen = Generator::new_unblinded(secp, asset_id.into_tag()); + output.blind_asset_proof = Some(Box::new(secp256k1_zkp::SurjectionProof::new( + secp, + rng, + asset_id.into_tag(), + out_abf.into_inner(), + &[(gen, asset_id.into_tag(), secp256k1_zkp::ZERO_TWEAK)], + )?)); + + output.blind_value_proof = Some(Box::new(secp256k1_zkp::RangeProof::new( + secp, + value, + value_commitment, + value, + out_vbf.into_inner(), + &[], + &[], + secp256k1_zkp::SecretKey::new(rng), + -1, + 0, + out_asset_commitment, + )?)); + + exp_out_secrets.push((value, out_abf, out_vbf)); + } + } + + Ok(()) +} diff --git a/lib/core/src/payjoin/pset/mod.rs b/lib/core/src/payjoin/pset/mod.rs new file mode 100644 index 0000000..0f42647 --- /dev/null +++ b/lib/core/src/payjoin/pset/mod.rs @@ -0,0 +1,121 @@ +pub(crate) mod blind; +mod tests; + +use anyhow::{anyhow, ensure, Result}; +use bip39::rand; +use lwk_wollet::bitcoin; +use lwk_wollet::elements::confidential::{Asset, Value}; +use lwk_wollet::elements::pset::{Input, Output, PartiallySignedTransaction}; +use lwk_wollet::elements::script::Script; +use lwk_wollet::elements::{self, AssetId, OutPoint, TxOut, TxOutSecrets, Txid}; +use rand::seq::SliceRandom; + +pub struct PsetInput { + pub txid: Txid, + pub vout: u32, + pub script_pub_key: Script, + pub asset_commitment: Asset, + pub value_commitment: Value, + pub tx_out_sec: TxOutSecrets, +} + +pub struct PsetOutput { + pub address: elements::Address, + pub asset_id: AssetId, + pub amount: u64, +} + +pub struct ConstructPsetRequest { + pub policy_asset: AssetId, + pub inputs: Vec, + pub outputs: Vec, + pub network_fee: u64, +} + +fn pset_input(input: PsetInput) -> Input { + let PsetInput { + txid, + vout, + script_pub_key, + asset_commitment, + value_commitment, + tx_out_sec: _, + } = input; + + let mut pset_input = Input::from_prevout(OutPoint { txid, vout }); + + pset_input.witness_utxo = Some(TxOut { + asset: asset_commitment, + value: value_commitment, + nonce: elements::confidential::Nonce::Null, + script_pubkey: script_pub_key, + witness: elements::TxOutWitness::default(), + }); + + pset_input +} + +fn pset_output(output: PsetOutput) -> Result { + let PsetOutput { + address, + asset_id, + amount, + } = output; + + let blinding_pubkey = address + .blinding_pubkey + .ok_or_else(|| anyhow!("only blinded addresses allowed"))?; + ensure!(amount > 0); + + let txout = TxOut { + asset: Asset::Explicit(asset_id), + value: Value::Explicit(amount), + nonce: elements::confidential::Nonce::Confidential(blinding_pubkey), + script_pubkey: address.script_pubkey(), + witness: elements::TxOutWitness::default(), + }; + + let mut output = Output::from_txout(txout); + + output.blinding_key = Some(bitcoin::PublicKey::new(blinding_pubkey)); + output.blinder_index = Some(0); + + Ok(output) +} + +fn pset_network_fee(asset: AssetId, amount: u64) -> Output { + let network_fee_output = TxOut::new_fee(amount, asset); + Output::from_txout(network_fee_output) +} + +pub fn construct_pset(req: ConstructPsetRequest) -> Result { + let ConstructPsetRequest { + policy_asset, + mut inputs, + mut outputs, + network_fee, + } = req; + + let mut pset = PartiallySignedTransaction::new_v2(); + let mut input_secrets = Vec::new(); + let blinding_factors = Vec::new(); + + let mut rng = rand::thread_rng(); + inputs.shuffle(&mut rng); + outputs.shuffle(&mut rng); + + for input in inputs.into_iter() { + input_secrets.push(input.tx_out_sec); + + pset.add_input(pset_input(input)); + } + + for output in outputs { + pset.add_output(pset_output(output)?); + } + + pset.add_output(pset_network_fee(policy_asset, network_fee)); + + blind::blind_pset(&mut pset, &input_secrets, &blinding_factors)?; + Ok(pset) +} diff --git a/lib/core/src/payjoin/pset/tests.rs b/lib/core/src/payjoin/pset/tests.rs new file mode 100644 index 0000000..e97d1b1 --- /dev/null +++ b/lib/core/src/payjoin/pset/tests.rs @@ -0,0 +1,200 @@ +#[cfg(test)] +mod tests { + use anyhow::Result; + use bip39::rand::{self, RngCore}; + use lwk_wollet::bitcoin; + use lwk_wollet::elements::address::AddressParams; + use lwk_wollet::elements::confidential::{ + Asset, AssetBlindingFactor, Value, ValueBlindingFactor, + }; + use lwk_wollet::elements::secp256k1_zkp::SecretKey; + use lwk_wollet::elements::{secp256k1_zkp, Address, AssetId, Script, TxOutSecrets, Txid}; + use std::str::FromStr; + + use crate::payjoin::pset::{construct_pset, ConstructPsetRequest, PsetInput, PsetOutput}; + + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + fn create_test_secret_key() -> SecretKey { + let mut rng = rand::thread_rng(); + let mut buf = [0u8; 32]; + rng.fill_bytes(&mut buf); + SecretKey::from_slice(&buf).expect("Expected valid secret key") + } + + fn create_test_input(asset_id: AssetId, sk: &SecretKey) -> PsetInput { + // Create a dummy txid + let txid = + Txid::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + + // Create a dummy script pubkey + let script_pub_key = + Script::from_str("76a914000000000000000000000000000000000000000088ac").unwrap(); + + // Create dummy asset and value commitments + let secp = secp256k1_zkp::Secp256k1::new(); + + let asset_bf = AssetBlindingFactor::from_slice(&sk.secret_bytes()).unwrap(); + let asset_gen = secp256k1_zkp::Generator::new_blinded( + &secp, + asset_id.into_tag(), + asset_bf.into_inner(), + ); + let asset_commitment = Asset::Confidential(asset_gen); + + // Create a Pedersen commitment for the value + let value_bf = ValueBlindingFactor::from_slice(&sk.secret_bytes()).unwrap(); + let value_commit = + secp256k1_zkp::PedersenCommitment::new(&secp, 10000, value_bf.into_inner(), asset_gen); + let value_commitment = Value::Confidential(value_commit); + + // Create dummy txout secrets + let tx_out_sec = TxOutSecrets { + asset: asset_id, + value: 10000, + asset_bf, + value_bf, + }; + + PsetInput { + txid, + vout: 0, + script_pub_key, + asset_commitment, + value_commitment, + tx_out_sec, + } + } + + fn create_test_output(asset_id: AssetId, sk: &SecretKey) -> PsetOutput { + // Create a dummy blinded address + let secp = secp256k1_zkp::Secp256k1::new(); + let blinding_key = + bitcoin::PublicKey::new(secp256k1_zkp::PublicKey::from_secret_key(&secp, sk)); + let address_pk = + bitcoin::PublicKey::new(secp256k1_zkp::PublicKey::from_secret_key(&secp, sk)); + + let address = Address::p2pkh( + &address_pk, + Some(blinding_key.inner), + &AddressParams::LIQUID, + ); + + PsetOutput { + address, + asset_id, + amount: 5000, + } + } + + #[sdk_macros::test_all] + fn test_construct_pset_basic() -> Result<()> { + // Create test data + let asset_id = AssetId::from_slice(&[2; 32]).unwrap(); + let secret_key = create_test_secret_key(); + + let policy_asset = AssetId::from_slice(&[8; 32]).unwrap(); + let inputs = vec![ + create_test_input(asset_id, &secret_key), + create_test_input(asset_id, &secret_key), + ]; + let outputs = vec![create_test_output(asset_id, &secret_key)]; + let network_fee = 1000; + + let request = ConstructPsetRequest { + policy_asset, + inputs, + outputs, + network_fee, + }; + + // Call the function + let pset = construct_pset(request)?; + + // Validate the result + assert_eq!(pset.inputs().len(), 2); + assert_eq!(pset.outputs().len(), 2); // 1 regular output + 1 fee output + + Ok(()) + } + + #[sdk_macros::test_all] + fn test_construct_pset_multiple_outputs() -> Result<()> { + // Create test data + let asset_id = AssetId::from_slice(&[3; 32]).unwrap(); + let secret_key = create_test_secret_key(); + + let policy_asset = AssetId::from_slice(&[8; 32]).unwrap(); + let inputs = vec![create_test_input(asset_id, &secret_key)]; + let outputs = vec![ + create_test_output(asset_id, &secret_key), + create_test_output(asset_id, &secret_key), + create_test_output(asset_id, &secret_key), + ]; + let network_fee = 1000; + + let request = ConstructPsetRequest { + policy_asset, + inputs, + outputs, + network_fee, + }; + + // Call the function + let pset = construct_pset(request)?; + + // Validate the result + assert_eq!(pset.inputs().len(), 1); + assert_eq!(pset.outputs().len(), 4); // 3 regular outputs + 1 fee output + + Ok(()) + } + + #[sdk_macros::test_all] + fn test_construct_pset_empty_inputs() { + // Create test data + let asset_id = AssetId::from_slice(&[4; 32]).unwrap(); + let secret_key = create_test_secret_key(); + + let policy_asset = AssetId::from_slice(&[8; 32]).unwrap(); + let inputs = vec![]; + let outputs = vec![create_test_output(asset_id, &secret_key)]; + let network_fee = 1000; + + let request = ConstructPsetRequest { + policy_asset, + inputs, + outputs, + network_fee, + }; + + // Blinding should fail with empty inputs + let result = construct_pset(request); + assert!(result.is_err()); + } + + #[sdk_macros::test_all] + fn test_construct_pset_empty_outputs() { + // Create test data + let asset_id = AssetId::from_slice(&[5; 32]).unwrap(); + let secret_key = create_test_secret_key(); + + let policy_asset = AssetId::from_slice(&[8; 32]).unwrap(); + let inputs = vec![create_test_input(asset_id, &secret_key)]; + let outputs = vec![]; + let network_fee = 1000; + + let request = ConstructPsetRequest { + policy_asset, + inputs, + outputs, + network_fee, + }; + + // Call the function + let result = construct_pset(request); + assert!(result.is_err()); + } +} diff --git a/lib/core/src/payjoin/side_swap.rs b/lib/core/src/payjoin/side_swap.rs new file mode 100644 index 0000000..76d4a6c --- /dev/null +++ b/lib/core/src/payjoin/side_swap.rs @@ -0,0 +1,681 @@ +use std::{collections::HashMap, str::FromStr}; + +use super::{ + error::{PayjoinError, PayjoinResult}, + model::{ + AcceptedAsset, AcceptedAssetsRequest, AcceptedAssetsResponse, Request, Response, + SignRequest, StartRequest, Utxo, + }, + pset::PsetInput, + utxo_select::utxo_select, + PayjoinService, +}; +use boltz_client::Secp256k1; +use log::{debug, error}; +use lwk_wollet::{ + bitcoin::base64::{self, Engine as _}, + elements::{ + self, + confidential::{self, AssetBlindingFactor, ValueBlindingFactor}, + pset::PartiallySignedTransaction, + secp256k1_zkp::Generator, + Address, AssetId, Transaction, TxOutSecrets, + }, +}; +use sdk_common::{ + ensure_sdk, + prelude::{parse_json, FiatAPI, RestClient}, + utils::Arc, +}; +use serde::{de::DeserializeOwned, Serialize}; +use tokio::sync::OnceCell; + +use crate::payjoin::{ + model::{InOut, Recipient}, + network_fee::TxFee, + pset::blind::remove_explicit_values, + utxo_select::UtxoSelectRequest, +}; +use crate::persist::Persister; +use crate::{ + model::{Config, LiquidNetwork}, + payjoin::pset::{construct_pset, ConstructPsetRequest, PsetOutput}, +}; +use crate::{utils, wallet::OnchainWallet}; + +const PRODUCTION_SIDESWAP_URL: &str = "https://api.sideswap.io/payjoin"; +const TESTNET_SIDESWAP_URL: &str = "https://api-testnet.sideswap.io/payjoin"; +// Base fee in USD represented in satoshis ($0.04) +const SIDESWAP_BASE_USD_FEE_SAT: f64 = 4_000_000.0; + +pub(crate) struct SideSwapPayjoinService { + config: Config, + fiat_api: Arc, + persister: Arc, + onchain_wallet: Arc, + rest_client: Arc, + accepted_assets: OnceCell, +} + +impl SideSwapPayjoinService { + pub fn new( + config: Config, + fiat_api: Arc, + persister: Arc, + onchain_wallet: Arc, + rest_client: Arc, + ) -> Self { + Self { + config, + fiat_api, + persister, + onchain_wallet, + rest_client, + accepted_assets: OnceCell::new(), + } + } + + fn get_url(&self) -> PayjoinResult<&str> { + match self.config.network { + LiquidNetwork::Mainnet => Ok(PRODUCTION_SIDESWAP_URL), + LiquidNetwork::Testnet => Ok(TESTNET_SIDESWAP_URL), + network => Err(PayjoinError::generic(format!( + "Payjoin not supported on {network}" + ))), + } + } + + async fn post_request(&self, body: &I) -> PayjoinResult { + let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]); + let body = serde_json::to_string(body)?; + debug!("Posting request to SideSwap: {body}"); + let (response, status_code) = self + .rest_client + .post(self.get_url()?, Some(headers), Some(body)) + .await?; + if status_code != 200 { + error!("Received status code {status_code} response from SideSwap"); + return Err(PayjoinError::service_connectivity(format!( + "Failed to post request to SideSwap: {response}" + ))); + } + debug!("Received response from SideSwap: {response}"); + Ok(parse_json(&response)?) + } +} + +#[sdk_macros::async_trait] +impl PayjoinService for SideSwapPayjoinService { + async fn fetch_accepted_assets(&self) -> PayjoinResult> { + let accepted_assets = self + .accepted_assets + .get_or_try_init(|| async { + debug!("Initializing accepted_assets from SideSwap"); + let accepted_assets_request = Request::AcceptedAssets(AcceptedAssetsRequest {}); + let response: Response = self.post_request(&accepted_assets_request).await?; + match response { + Response::AcceptedAssets(accepted_assets) => Ok(accepted_assets), + _ => Err(PayjoinError::service_connectivity( + "Failed to request accepted assets from SideSwap", + )), + } + }) + .await?; + + Ok(accepted_assets.accepted_asset.clone()) + } + + async fn estimate_payjoin_tx_fee(&self, asset_id: &str, amount_sat: u64) -> PayjoinResult { + // Check the asset is accepted + let fee_asset = AssetId::from_str(asset_id)?; + let accepted_assets = self.fetch_accepted_assets().await?; + ensure_sdk!( + accepted_assets + .iter() + .any(|asset| asset.asset_id == asset_id), + PayjoinError::generic("Asset not accepted by SideSwap") + ); + + // Get and check the wallet asset balance + let wallet_asset_balance: u64 = self + .onchain_wallet + .asset_utxos(&fee_asset) + .await? + .iter() + .map(|utxo| utxo.unblinded.value) + .sum(); + ensure_sdk!( + wallet_asset_balance > amount_sat, + PayjoinError::InsufficientFunds + ); + + // Fetch the fiat rates + let asset_metadata = + self.persister + .get_asset_metadata(asset_id)? + .ok_or(PayjoinError::generic(format!( + "No asset metadata available for {asset_id}" + )))?; + let Some(fiat_id) = asset_metadata.fiat_id.clone() else { + return Err(PayjoinError::generic(format!( + "No fiat ID available in asset metadata for {asset_id}" + ))); + }; + let fiat_rates = self.fiat_api.fetch_fiat_rates().await?; + let usd_index_price = fiat_rates + .iter() + .find(|rate| rate.coin == "USD") + .map(|rate| rate.value) + .ok_or(PayjoinError::generic("No rate available for USD"))?; + let asset_index_price = fiat_rates + .iter() + .find(|rate| rate.coin == fiat_id) + .map(|rate| rate.value) + .ok_or(PayjoinError::generic(format!( + "No rate available for {fiat_id}" + )))?; + + let fixed_fee = (SIDESWAP_BASE_USD_FEE_SAT / usd_index_price * asset_index_price) as u64; + // Fees assuming we have: + // - 1 input for the server (lbtc) + // - 1 input for the user (asset) + // - 1 output for the user (asset change) + // - 1 output for the recipient (asset) + // - 1 output for the server (asset fee) + // - 1 output for the server (lbtc change) + let network_fee = TxFee { + server_inputs: 1, + user_inputs: 1, + outputs: 4, + } + .fee(); + let fee_sat = (network_fee as f64 * asset_index_price) as u64 + fixed_fee; + ensure_sdk!( + wallet_asset_balance >= amount_sat + fee_sat, + PayjoinError::InsufficientFunds + ); + + // The estimation accuracy gives a fee to two decimal places + let mut fee = asset_metadata.amount_from_sat(fee_sat); + fee = (fee * 100.0).ceil() / 100.0; + + debug!("Estimated payjoin server fee: {fee} ({fee_sat} satoshi units)"); + + Ok(fee) + } + + async fn build_payjoin_tx( + &self, + recipient_address: &str, + asset_id: &str, + amount_sat: u64, + ) -> PayjoinResult<(Transaction, u64)> { + let fee_asset = AssetId::from_str(asset_id)?; + let wallet_utxos = self + .onchain_wallet + .asset_utxos(&fee_asset) + .await? + .iter() + .map(Utxo::from) + .collect::>(); + ensure_sdk!(!wallet_utxos.is_empty(), PayjoinError::InsufficientFunds); + + let address = Address::from_str(recipient_address).map_err(|e| { + PayjoinError::generic(format!( + "Recipient address {recipient_address} is not a valid ElementsAddress: {e:?}" + )) + })?; + let recipients = vec![Recipient { + address, + asset_id: fee_asset, + amount: amount_sat, + }]; + + let start_request = Request::Start(StartRequest { + asset_id: asset_id.to_string(), + user_agent: "breezsdk".to_string(), + api_key: self.config.sideswap_api_key.clone(), + }); + let response: Response = self.post_request(&start_request).await?; + let Response::Start(start_response) = response else { + return Err(PayjoinError::service_connectivity( + "Failed to start payjoin", + )); + }; + ensure_sdk!( + start_response.fee_address.is_blinded(), + PayjoinError::generic("Server fee address is not blinded") + ); + ensure_sdk!( + start_response.change_address.is_blinded(), + PayjoinError::generic("Server change address is not blinded") + ); + ensure_sdk!( + !start_response.utxos.is_empty(), + PayjoinError::generic("Server utxos are empty") + ); + + let policy_asset = utils::lbtc_asset_id(self.config.network); + let utxo_select_res = utxo_select(UtxoSelectRequest { + policy_asset, + fee_asset, + price: start_response.price, + fixed_fee: start_response.fixed_fee, + wallet_utxos: wallet_utxos.iter().map(Into::into).collect(), + server_utxos: start_response.utxos.iter().map(Into::into).collect(), + user_outputs: recipients + .iter() + .map(|recipient| InOut { + asset_id: recipient.asset_id, + value: recipient.amount, + }) + .collect(), + })?; + ensure_sdk!( + utxo_select_res.user_outputs.len() == recipients.len(), + PayjoinError::generic("Output/recipient lengths mismatch") + ); + + let mut inputs = Vec::new(); + let mut outputs = Vec::new(); + + // Set the wallet and server inputs + inputs.append(&mut select_utxos( + wallet_utxos, + utxo_select_res + .user_inputs + .into_iter() + .chain(utxo_select_res.client_inputs.into_iter()) + .collect(), + )?); + inputs.append(&mut select_utxos( + start_response.utxos, + utxo_select_res.server_inputs, + )?); + + // Set the outputs + let server_fee = utxo_select_res.server_fee; + + // Recipient outputs + for (output, recipient) in utxo_select_res + .user_outputs + .iter() + .zip(recipients.into_iter()) + { + debug!( + "Payjoin recipent output: {} value: {}", + recipient.address, output.value + ); + outputs.push(PsetOutput { + asset_id: output.asset_id, + amount: output.value, + address: recipient.address, + }); + } + + // Change outputs + for output in utxo_select_res + .change_outputs + .iter() + .chain(utxo_select_res.fee_change.iter()) + { + let address = self.onchain_wallet.next_unused_change_address().await?; + debug!("Payjoin change output: {address} value: {}", output.value); + outputs.push(PsetOutput { + asset_id: output.asset_id, + amount: output.value, + address, + }); + } + + // Server fee output + debug!( + "Payjoin server fee output: {} value: {}", + start_response.fee_address, server_fee.value + ); + outputs.push(PsetOutput { + asset_id: server_fee.asset_id, + amount: server_fee.value, + address: start_response.fee_address, + }); + + // Server change output + if let Some(output) = utxo_select_res.server_change { + debug!( + "Payjoin server change output: {} value: {}", + start_response.change_address, output.value + ); + outputs.push(PsetOutput { + asset_id: output.asset_id, + amount: output.value, + address: start_response.change_address, + }); + } + + // Construct the PSET + let blinded_pset = construct_pset(ConstructPsetRequest { + policy_asset, + inputs, + outputs, + network_fee: utxo_select_res.network_fee.value, + })?; + + let mut pset = blinded_pset.clone(); + remove_explicit_values(&mut pset); + let server_pset = elements::encode::serialize(&pset); + + // Send the signing request + let sign_request = Request::Sign(SignRequest { + order_id: start_response.order_id, + pset: base64::engine::general_purpose::STANDARD.encode(&server_pset), + }); + let response: Response = self.post_request(&sign_request).await?; + let Response::Sign(sign_response) = response else { + return Err(PayjoinError::service_connectivity("Failed to sign payjoin")); + }; + + // Copy the signed inputs to the blinded PSET + let server_signed_pset = elements::encode::deserialize::( + &base64::engine::general_purpose::STANDARD.decode(&sign_response.pset)?, + )?; + let server_signed_blinded_pset = copy_signatures(blinded_pset, server_signed_pset)?; + + let tx = self + .onchain_wallet + .sign_pset(server_signed_blinded_pset) + .await?; + Ok((tx, server_fee.value)) + } +} + +impl From<&Utxo> for InOut { + fn from(utxo: &Utxo) -> Self { + Self { + asset_id: utxo.asset_id, + value: utxo.value, + } + } +} + +fn copy_signatures( + mut dst_pset: PartiallySignedTransaction, + src_pset: PartiallySignedTransaction, +) -> PayjoinResult { + ensure_sdk!( + dst_pset.inputs().len() == src_pset.inputs().len(), + PayjoinError::generic("Input lengths mismatch") + ); + ensure_sdk!( + dst_pset.outputs().len() == src_pset.outputs().len(), + PayjoinError::generic("Output lengths mismatch") + ); + for (dst_input, src_input) in dst_pset + .inputs_mut() + .iter_mut() + .zip(src_pset.inputs().iter()) + { + if src_input.final_script_witness.is_some() { + dst_input.final_script_sig = src_input.final_script_sig.clone(); + dst_input.final_script_witness = src_input.final_script_witness.clone(); + } + } + Ok(dst_pset) +} + +fn select_utxos(mut utxos: Vec, in_outs: Vec) -> PayjoinResult> { + let secp = Secp256k1::new(); + let mut selected = Vec::new(); + for in_out in in_outs { + let index = utxos + .iter() + .position(|utxo| utxo.asset_id == in_out.asset_id && utxo.value == in_out.value) + .ok_or(PayjoinError::generic("Failed to find utxo"))?; + let utxo = utxos.remove(index); + + let (asset_commitment, value_commitment) = if utxo.asset_bf == AssetBlindingFactor::zero() + || utxo.value_bf == ValueBlindingFactor::zero() + { + ( + confidential::Asset::Explicit(utxo.asset_id), + confidential::Value::Explicit(utxo.value), + ) + } else { + let gen = + Generator::new_blinded(&secp, utxo.asset_id.into_tag(), utxo.asset_bf.into_inner()); + ( + confidential::Asset::Confidential(gen), + confidential::Value::new_confidential(&secp, utxo.value, gen, utxo.value_bf), + ) + }; + + let input = PsetInput { + txid: utxo.txid, + vout: utxo.vout, + script_pub_key: utxo.script_pub_key, + asset_commitment, + value_commitment, + tx_out_sec: TxOutSecrets { + asset: utxo.asset_id, + asset_bf: utxo.asset_bf, + value: utxo.value, + value_bf: utxo.value_bf, + }, + }; + debug!("Payjoin input: {} vout: {}", input.txid, input.vout); + selected.push(input); + } + Ok(selected) +} + +#[cfg(test)] +mod tests { + use super::*; + + use anyhow::Result; + use lwk_wollet::{ + elements::{OutPoint, Script, Txid}, + Chain, WalletTxOut, + }; + use sdk_common::prelude::{BreezServer, MockResponse, MockRestClient, STAGING_BREEZSERVER_URL}; + use serde_json::json; + + use crate::{ + model::Signer, + test_utils::{ + persist::create_persister, + wallet::{MockSigner, MockWallet}, + }, + }; + + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + fn create_sideswap_payjoin_service( + persister: Arc, + ) -> Result<(Arc, Arc, SideSwapPayjoinService)> { + let config = Config::testnet_esplora(None); + let breez_server = Arc::new(BreezServer::new(STAGING_BREEZSERVER_URL.to_string(), None)?); + let signer: Arc> = Arc::new(Box::new(MockSigner::new()?)); + let onchain_wallet = Arc::new(MockWallet::new(signer.clone())?); + let rest_client = Arc::new(MockRestClient::new()); + + Ok(( + onchain_wallet.clone(), + rest_client.clone(), + SideSwapPayjoinService::new( + config, + breez_server, + persister, + onchain_wallet, + rest_client, + ), + )) + } + + fn create_utxos(asset: AssetId, values: Vec) -> Vec { + let txid = + Txid::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + let script_pubkey = + Script::from_str("76a914000000000000000000000000000000000000000088ac").unwrap(); + + values.into_iter().map(|value| { + WalletTxOut { + outpoint: OutPoint::new(txid, 0), + script_pubkey: script_pubkey.clone(), + height: Some(10), + unblinded: TxOutSecrets { + asset, + value, + asset_bf: AssetBlindingFactor::zero(), + value_bf: ValueBlindingFactor::zero(), + }, + wildcard_index: 0, + ext_int: Chain::Internal, + is_spent: false, + address: Address::from_str("lq1pqw8ct25kd47dejyesyvk3g2kaf8s9uhq4se7r2kj9y9hhvu9ug5thxlpn9y63s78kc2mcp6nujavckvr42q7hwkhqq9hfz46nth22hfp3em0ulm4nsuf").unwrap(), + } + }).collect() + } + + #[sdk_macros::async_test_all] + async fn test_fetch_accepted_assets_error() -> Result<()> { + create_persister!(persister); + let (_, mock_rest_client, payjoin_service) = + create_sideswap_payjoin_service(persister).unwrap(); + + mock_rest_client.add_response(MockResponse::new(400, "".to_string())); + + let res = payjoin_service.fetch_accepted_assets().await; + assert!(res.is_err()); + + Ok(()) + } + + #[sdk_macros::async_test_all] + async fn test_fetch_accepted_assets() -> Result<()> { + create_persister!(persister); + let (_, mock_rest_client, payjoin_service) = + create_sideswap_payjoin_service(persister).unwrap(); + let asset_id = AssetId::from_slice(&[2; 32]).unwrap().to_string(); + + let response_body = + json!({"accepted_assets": {"accepted_asset":[{"asset_id": asset_id}]}}).to_string(); + mock_rest_client.add_response(MockResponse::new(200, response_body)); + + let res = payjoin_service.fetch_accepted_assets().await; + assert!(res.is_ok()); + let accepted_assets = res.unwrap(); + assert_eq!(accepted_assets.len(), 1); + assert_eq!(accepted_assets[0].asset_id, asset_id); + + Ok(()) + } + + #[sdk_macros::async_test_all] + async fn test_estimate_payjoin_tx_fee_error() -> Result<()> { + create_persister!(persister); + let (_, mock_rest_client, payjoin_service) = + create_sideswap_payjoin_service(persister).unwrap(); + let asset_id = AssetId::from_slice(&[2; 32]).unwrap().to_string(); + + mock_rest_client.add_response(MockResponse::new(400, "".to_string())); + + let amount_sat = 500_000; + let res = payjoin_service + .estimate_payjoin_tx_fee(&asset_id, amount_sat) + .await; + assert!(res.is_err()); + + Ok(()) + } + + #[sdk_macros::async_test_all] + async fn test_estimate_payjoin_tx_fee_no_utxos() -> Result<()> { + create_persister!(persister); + let (_, mock_rest_client, payjoin_service) = + create_sideswap_payjoin_service(persister).unwrap(); + let asset_id = AssetId::from_slice(&[2; 32]).unwrap().to_string(); + + let response_body = + json!({"accepted_assets": {"accepted_asset":[{"asset_id": asset_id}]}}).to_string(); + mock_rest_client.add_response(MockResponse::new(200, response_body)); + + let amount_sat = 500_000; + let res = payjoin_service + .estimate_payjoin_tx_fee(&asset_id, amount_sat) + .await; + assert!(res.is_err()); + assert_eq!(res.unwrap_err().to_string(), "Cannot pay: not enough funds"); + + Ok(()) + } + + #[sdk_macros::async_test_all] + async fn test_estimate_payjoin_tx_fee_no_asset_metadata() -> Result<()> { + create_persister!(persister); + let (mock_wallet, mock_rest_client, payjoin_service) = + create_sideswap_payjoin_service(persister).unwrap(); + let asset_id = AssetId::from_slice(&[2; 32]).unwrap(); + let asset_id_str = asset_id.to_string(); + + // Mock the accepted assets response + let accepted_assets_response = json!({ + "accepted_assets": { + "accepted_asset":[{"asset_id": asset_id_str}] + } + }) + .to_string(); + mock_rest_client.add_response(MockResponse::new(200, accepted_assets_response)); + + // Set up the mock wallet to return some UTXOs for the test asset + let utxos = create_utxos(asset_id, vec![1_000_000]); + mock_wallet.set_utxos(utxos); + + let amount_sat = 500_000; + let res = payjoin_service + .estimate_payjoin_tx_fee(&asset_id_str, amount_sat) + .await; + + assert_eq!(res.unwrap_err().to_string(), "No asset metadata available for 0202020202020202020202020202020202020202020202020202020202020202"); + + Ok(()) + } + + #[sdk_macros::async_test_all] + #[ignore = "Requires a mockable FiatAPI"] + + async fn test_estimate_payjoin_tx_fee() -> Result<()> { + create_persister!(persister); + let (mock_wallet, mock_rest_client, payjoin_service) = + create_sideswap_payjoin_service(persister).unwrap(); + let asset_id = + AssetId::from_str("b612eb46313a2cd6ebabd8b7a8eed5696e29898b87a43bff41c94f51acef9d73") + .unwrap(); + let asset_id_str = asset_id.to_string(); + + // Mock the accepted assets response + let accepted_assets_response = json!({ + "accepted_assets": { + "accepted_asset":[{"asset_id": asset_id_str}] + } + }) + .to_string(); + mock_rest_client.add_response(MockResponse::new(200, accepted_assets_response)); + + // TODO: Mock the FiatAPI response as the staging BreezServer currently times out + + // Set up the mock wallet to return some UTXOs for the test asset + let utxos = create_utxos(asset_id, vec![1_000_000]); + mock_wallet.set_utxos(utxos); + + let amount_sat = 500_000; + let res = payjoin_service + .estimate_payjoin_tx_fee(&asset_id_str, amount_sat) + .await; + + assert_eq!(res.unwrap_err().to_string(), "Cannot pay: not enough funds"); + + Ok(()) + } +} diff --git a/lib/core/src/payjoin/utxo_select/asset.rs b/lib/core/src/payjoin/utxo_select/asset.rs new file mode 100644 index 0000000..d42d9c3 --- /dev/null +++ b/lib/core/src/payjoin/utxo_select/asset.rs @@ -0,0 +1,95 @@ +use std::collections::BTreeMap; + +use anyhow::{anyhow, ensure, Result}; +use lwk_wollet::elements::AssetId; + +use crate::payjoin::utxo_select::{utxo_select_best, InOut}; + +pub(crate) struct AssetSelectRequest { + pub fee_asset: AssetId, + pub wallet_utxos: Vec, + pub user_outputs: Vec, +} + +pub(crate) struct AssetSelectResult { + pub asset_inputs: Vec, + pub user_outputs: Vec, + pub change_outputs: Vec, + pub user_output_amounts: BTreeMap, +} + +pub(crate) fn asset_select( + AssetSelectRequest { + fee_asset, + wallet_utxos, + user_outputs, + }: AssetSelectRequest, +) -> Result { + let mut user_output_amounts = BTreeMap::::new(); + + for input in wallet_utxos.iter() { + ensure!(input.value > 0, anyhow!("Invalid amount {:?}", input)); + } + + for user_output in user_outputs.iter() { + ensure!( + user_output.value > 0, + anyhow!("Invalid amount {:?}", user_output), + ); + *user_output_amounts.entry(user_output.asset_id).or_default() += user_output.value; + } + + let mut asset_inputs = Vec::::new(); + let mut change_outputs = Vec::::new(); + + for (&asset_id, &target_value) in user_output_amounts.iter() { + if asset_id != fee_asset { + let wallet_utxo = wallet_utxos + .iter() + .filter(|utxo| utxo.asset_id == asset_id) + .map(|utxo| utxo.value) + .collect::>(); + let available = wallet_utxo.iter().sum::(); + + ensure!( + available >= target_value, + anyhow!( + "Not enough UTXOs for asset {}, required: {}, available: {}", + asset_id, + target_value, + available + ) + ); + + let selected = + utxo_select_best(target_value, &wallet_utxo).ok_or(anyhow!("No utxos selected"))?; + + let mut total_value = 0; + for value in selected { + asset_inputs.push(InOut { asset_id, value }); + total_value += value; + } + + ensure!( + total_value >= target_value, + "Total value is less than target: {} < {}", + total_value, + target_value + ); + let change_amount = total_value - target_value; + if change_amount > 0 { + change_outputs.push(InOut { + asset_id, + value: change_amount, + }); + } + } + } + + Ok(AssetSelectResult { + asset_inputs, + user_outputs, + change_outputs, + user_output_amounts, + }) +} diff --git a/lib/core/src/payjoin/utxo_select/mod.rs b/lib/core/src/payjoin/utxo_select/mod.rs new file mode 100644 index 0000000..9165116 --- /dev/null +++ b/lib/core/src/payjoin/utxo_select/mod.rs @@ -0,0 +1,465 @@ +mod asset; +mod tests; + +use std::collections::BTreeMap; + +use anyhow::{anyhow, ensure, Result}; +use asset::{asset_select, AssetSelectRequest, AssetSelectResult}; +use lwk_wollet::elements::AssetId; + +use crate::payjoin::{ + model::InOut, + network_fee::{self, TxFee}, +}; + +// TOTAL_TRIES in Core: +// https://github.com/bitcoin/bitcoin/blob/1d9da8da309d1dbf9aef15eb8dc43b4a2dc3d309/src/wallet/coinselection.cpp#L74 +const UTXO_SELECTION_ITERATION_LIMIT: u32 = 100_000; + +#[derive(Debug, Clone)] +pub(crate) struct UtxoSelectRequest { + pub policy_asset: AssetId, + pub fee_asset: AssetId, + pub price: f64, + pub fixed_fee: u64, + pub wallet_utxos: Vec, + pub server_utxos: Vec, + pub user_outputs: Vec, +} + +#[derive(Debug)] +pub(crate) struct UtxoSelectResult { + pub user_inputs: Vec, + pub client_inputs: Vec, + pub server_inputs: Vec, + + pub user_outputs: Vec, + pub change_outputs: Vec, + pub server_fee: InOut, + pub server_change: Option, + pub fee_change: Option, + pub network_fee: InOut, + + pub cost: u64, +} + +pub(crate) fn utxo_select(req: UtxoSelectRequest) -> Result { + let utxo_select_res = utxo_select_inner(req.clone()); + + if let Ok(res) = &utxo_select_res { + validate_selection(&req, res)?; + } + utxo_select_res +} + +pub(crate) fn utxo_select_fixed( + target_value: u64, + target_utxo_count: usize, + utxos: &[u64], +) -> Option> { + let selected_utxos = utxos + .iter() + .copied() + .take(target_utxo_count) + .collect::>(); + let selected_value = selected_utxos.iter().sum::(); + if selected_value < target_value { + None + } else { + Some(selected_utxos) + } +} + +pub(crate) fn utxo_select_basic(target_value: u64, utxos: &[u64]) -> Option> { + let mut selected_utxos = Vec::new(); + let mut selected_value = 0; + + if target_value > 0 { + for utxo in utxos { + selected_utxos.push(*utxo); + selected_value += utxo; + if selected_value >= target_value { + break; + } + } + } + + if selected_value < target_value { + None + } else { + Some(selected_utxos) + } +} + +pub(crate) fn utxo_select_best(target_value: u64, utxos: &[u64]) -> Option> { + utxo_select_in_range(target_value, 0, 0, utxos) + .or_else(|| utxo_select_basic(target_value, utxos)) +} + +/// Try to select utxos so that their sum is in the range [target_value..target_value + upper_bound_delta]. +/// Set `upper_bound_delta` to 0 if you want to find utxos without change. +/// All the values must be "sane" so their sum does not overflow. +fn utxo_select_in_range( + target_value: u64, + upper_bound_delta: u64, + target_utxo_count: usize, + utxos: &[u64], +) -> Option> { + let mut utxos = utxos.to_vec(); + utxos.sort(); + utxos.reverse(); + + let mut iteration = 0; + let mut index = 0; + let mut value = 0; + + let mut current_change = 0; + let mut best_change = u64::MAX; + + let mut index_selection: Vec = vec![]; + let mut best_selection: Option> = None; + + let upper_bound = target_value + upper_bound_delta; + let mut available_value = utxos.iter().sum::(); + + if available_value < target_value { + return None; + } + + while iteration < UTXO_SELECTION_ITERATION_LIMIT { + let mut step_back = false; + + if available_value + value < target_value + // If any of the conditions are met, step back. + // + // Provides an upper bound on the change value that is allowed. + // Since value is lost when we create a change output due to increasing the size of the + // transaction by an output (the change output), we accept solutions that may be + // larger than the target. The change is added to the solutions change score. + // However, values greater than value + upper_bound_delta are not considered. + // + // This creates a range of possible solutions where; + // range = (target, target + upper_bound_delta] + // + // That is, the range includes solutions that exactly equal the target up to but not + // including values greater than target + upper_bound_delta. + || value > upper_bound + || current_change > best_change + { + step_back = true; + } else if value >= target_value { + // Value meets or exceeds the target. + // Record the solution and the change then continue. + step_back = true; + + let change = value - target_value; + current_change += change; + + // Check if index_selection is better than the previous known best, and + // update best_selection accordingly. + if current_change <= best_change + && ((target_utxo_count == 0 + && best_selection.clone().is_none_or(|best_selection| { + index_selection.len() <= best_selection.len() + })) + || index_selection.len() == target_utxo_count) + { + best_selection = Some(index_selection.clone()); + best_change = current_change; + } + + current_change = current_change.saturating_sub(change); + } + + if target_utxo_count != 0 && index_selection.len() >= target_utxo_count { + step_back = true; + } + + if step_back { + // Step back + if index_selection.is_empty() { + break; + } + + loop { + index -= 1; + + if index_selection.last().is_none_or(|last| index <= *last) { + break; + } + + let utxo_value = utxos[index]; + available_value += utxo_value; + } + + if index_selection.last().is_some_and(|last| index == *last) { + let utxo_value = utxos[index]; + value = value.saturating_sub(utxo_value); + index_selection.pop(); + } + } else { + // Add the utxo to the current selection + let utxo_value = utxos[index]; + + index_selection.push(index); + + value += utxo_value; + available_value = available_value.saturating_sub(utxo_value); + } + + index += 1; + iteration += 1; + } + + best_selection.map(|best_selection| best_selection.iter().map(|index| utxos[*index]).collect()) +} + +fn utxo_select_inner( + UtxoSelectRequest { + policy_asset, + fee_asset, + price, + fixed_fee, + wallet_utxos, + server_utxos, + user_outputs, + }: UtxoSelectRequest, +) -> Result { + ensure!(fee_asset != policy_asset); + ensure!(price > 0.0); + ensure!(fixed_fee > 0); + ensure!(wallet_utxos.iter().all(|utxo| utxo.value > 0)); + + ensure!(server_utxos + .iter() + .all(|utxo| utxo.asset_id == policy_asset && utxo.value > 0)); + + let fee_utoxs = wallet_utxos + .iter() + .filter(|utxo| utxo.asset_id == fee_asset) + .map(|utxo| utxo.value) + .collect::>(); + let mut server_utxos = server_utxos + .iter() + .map(|utxo| utxo.value) + .collect::>(); + server_utxos.sort(); + server_utxos.reverse(); + + let AssetSelectResult { + asset_inputs, + user_outputs, + change_outputs, + user_output_amounts, + } = asset_select(AssetSelectRequest { + fee_asset, + wallet_utxos, + user_outputs, + })?; + + let mut best_selection: Option = None; + + for with_fee_change in [false, true] { + for with_server_change in [false, true] { + for server_input_count in 1..=server_utxos.len() { + for fee_input_count in 1..=fee_utoxs.len() { + if fee_input_count != fee_utoxs.len() { + continue; + } + let user_input_count = asset_inputs.len() + fee_input_count; + + let output_count = user_outputs.len() + + change_outputs.len() + + usize::from(with_fee_change) + + usize::from(with_server_change) + + 1; // Server fee output + + let min_network_fee = TxFee { + server_inputs: server_input_count, + user_inputs: user_input_count, + outputs: output_count, + } + .fee(); + + let server_inputs = if with_server_change { + utxo_select_fixed(min_network_fee + 1, server_input_count, &server_utxos) + } else { + let upper_bound_delta = network_fee::weight_to_fee( + network_fee::WEIGHT_VOUT_NESTED, + network_fee::MIN_FEE_RATE, + ); + utxo_select_in_range( + min_network_fee, + upper_bound_delta, + server_input_count, + &server_utxos, + ) + }; + + let server_inputs = match server_inputs { + Some(server_inputs) => server_inputs, + None => continue, + }; + + let server_input = server_inputs.iter().sum::(); + let server_change = if with_server_change { + server_input - min_network_fee + } else { + 0 + }; + let network_fee = server_input - server_change; + let min_asset_fee = (network_fee as f64 * price) as u64 + fixed_fee; + + let user_asset_output = user_output_amounts + .get(&fee_asset) + .copied() + .unwrap_or_default(); + + let fee_asset_target = user_asset_output + min_asset_fee; + let fee_asset_inputs = if with_fee_change { + utxo_select_fixed(fee_asset_target + 1, fee_input_count, &fee_utoxs) + } else { + let upper_bound_delta = (network_fee::weight_to_fee( + network_fee::WEIGHT_VOUT_NESTED, + network_fee::MIN_FEE_RATE, + ) as f64 + * price) as u64; + utxo_select_in_range( + fee_asset_target, + upper_bound_delta, + fee_input_count, + &fee_utoxs, + ) + }; + + let fee_asset_inputs = match fee_asset_inputs { + Some(fee_asset_inputs) => fee_asset_inputs, + None => continue, + }; + + let fee_input = fee_asset_inputs.iter().sum::(); + let fee_change = if with_fee_change { + fee_input - fee_asset_target + } else { + 0 + }; + let server_fee = fee_input - fee_change - user_asset_output; + let new_cost = server_fee; + + if best_selection + .as_ref() + .map(|best| best.cost > new_cost) + .unwrap_or(true) + { + let user_outputs = user_outputs.clone(); + + best_selection = Some(UtxoSelectResult { + user_inputs: asset_inputs.clone(), + client_inputs: fee_asset_inputs + .iter() + .map(|value| InOut { + asset_id: fee_asset, + value: *value, + }) + .collect(), + server_inputs: server_inputs + .iter() + .map(|value| InOut { + asset_id: policy_asset, + value: *value, + }) + .collect(), + user_outputs, + change_outputs: change_outputs.clone(), + server_fee: InOut { + asset_id: fee_asset, + value: server_fee, + }, + server_change: with_server_change.then_some(InOut { + asset_id: policy_asset, + value: server_change, + }), + fee_change: with_fee_change.then_some(InOut { + asset_id: fee_asset, + value: fee_change, + }), + network_fee: InOut { + asset_id: policy_asset, + value: network_fee, + }, + cost: new_cost, + }) + }; + } + } + } + } + + best_selection.ok_or_else(|| anyhow!("Utxo selection failed")) +} + +fn validate_selection( + req: &UtxoSelectRequest, + UtxoSelectResult { + user_inputs, + client_inputs, + server_inputs, + user_outputs, + change_outputs, + server_fee, + server_change, + fee_change, + network_fee, + cost: _, + }: &UtxoSelectResult, +) -> Result<()> { + let mut inputs = BTreeMap::::new(); + let mut outputs = BTreeMap::::new(); + + for input in user_inputs + .iter() + .chain(client_inputs.iter()) + .chain(server_inputs.iter()) + { + *inputs.entry(input.asset_id).or_default() += input.value; + } + + for output in user_outputs + .iter() + .chain(change_outputs.iter()) + .chain(std::iter::once(server_fee)) + .chain(server_change.iter()) + .chain(fee_change.iter()) + .chain(std::iter::once(network_fee)) + { + *outputs.entry(output.asset_id).or_default() += output.value; + } + + ensure!(inputs == outputs, "Check failed: {inputs:?} != {outputs:?}"); + + let client_input_count = user_inputs.len() + client_inputs.len(); + let server_input_count = server_inputs.len(); + let output_count = user_outputs.len() + + change_outputs.len() + + 1 + + usize::from(server_change.is_some()) + + usize::from(fee_change.is_some()); + + let min_network_fee = TxFee { + server_inputs: server_input_count, + user_inputs: client_input_count, + outputs: output_count, + } + .fee(); + + let actual_network_fee = network_fee.value; + ensure!(actual_network_fee >= min_network_fee); + ensure!(actual_network_fee <= 2 * min_network_fee); + + let min_server_fee = (actual_network_fee as f64 * req.price) as u64 + req.fixed_fee; + let actual_server_fee = server_fee.value; + ensure!(actual_server_fee >= min_server_fee); + ensure!(actual_server_fee <= 2 * min_server_fee); + + Ok(()) +} diff --git a/lib/core/src/payjoin/utxo_select/tests.rs b/lib/core/src/payjoin/utxo_select/tests.rs new file mode 100644 index 0000000..cb751e3 --- /dev/null +++ b/lib/core/src/payjoin/utxo_select/tests.rs @@ -0,0 +1,388 @@ +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use lwk_wollet::elements::AssetId; + + use crate::payjoin::{ + model::InOut, + utxo_select::{ + utxo_select, utxo_select_basic, utxo_select_best, utxo_select_fixed, + utxo_select_in_range, UtxoSelectRequest, + }, + }; + + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[sdk_macros::test_all] + fn test_utxo_select_basic() { + // Basic case - should select UTXOs in order until target is met + let utxos = vec![100, 200, 300, 400]; + let selected = utxo_select_basic(300, &utxos); + assert_eq!(selected, Some(vec![100, 200])); + + // Exact match with one UTXO + let selected = utxo_select_basic(300, &[300, 400, 500]); + assert_eq!(selected, Some(vec![300])); + + // First UTXO is enough + let selected = utxo_select_basic(50, &[100, 200, 300]); + assert_eq!(selected, Some(vec![100])); + + // Need all UTXOs + let selected = utxo_select_basic(590, &[100, 200, 300]); + assert_eq!(selected, Some(vec![100, 200, 300])); + + // Not enough UTXOs available + let selected = utxo_select_basic(1000, &[100, 200, 300]); + assert_eq!(selected, None); + + // Empty UTXO list + let selected = utxo_select_basic(100, &[]); + assert_eq!(selected, None); + + // Zero target amount + let selected = utxo_select_basic(0, &[100, 200]); + assert_eq!(selected, Some(vec![])); + + // Large values to check for overflow + let large_value = u64::MAX / 3; + let utxos = vec![large_value, large_value, large_value]; + let selected = utxo_select_basic(large_value * 2, &utxos); + assert_eq!(selected, Some(vec![large_value, large_value])); + + // UTXO order matters - should take in original order + let utxos = vec![400, 100, 300, 200]; + let selected = utxo_select_basic(450, &utxos); + assert_eq!(selected, Some(vec![400, 100])); + + // With just-enough UTXOs + let utxos = vec![100, 200, 300, 400]; + let selected = utxo_select_basic(1000, &utxos); + assert_eq!(selected, Some(vec![100, 200, 300, 400])); + } + + #[sdk_macros::test_all] + fn test_utxo_select_fixed() { + let utxos = vec![100, 200, 300, 400]; + + // Should take first two UTXOs (100 + 200 = 300) + let selected = utxo_select_fixed(300, 2, &utxos); + assert_eq!(selected, Some(vec![100, 200])); + + // Not enough with just one UTXO + let selected = utxo_select_fixed(150, 1, &utxos); + assert_eq!(selected, None); + + // Target exceeds available in requested count + let selected = utxo_select_fixed(350, 2, &utxos); + assert_eq!(selected, None); + + // With exactly the required amount + let selected = utxo_select_fixed(300, 1, &[300]); + assert_eq!(selected, Some(vec![300])); + + // With empty utxos + let selected = utxo_select_fixed(100, 1, &[]); + assert_eq!(selected, None); + + // With zero target value + let selected = utxo_select_fixed(0, 2, &utxos); + assert_eq!(selected, Some(vec![100, 200])); + + // With zero target count + let selected = utxo_select_fixed(100, 0, &utxos); + assert_eq!(selected, None); + + // With more UTXOs than requested count but still not enough value + let selected = utxo_select_fixed(1000, 3, &utxos); + assert_eq!(selected, None); + + // With exactly enough UTXOs to meet the target + let selected = utxo_select_fixed(600, 3, &utxos); + assert_eq!(selected, Some(vec![100, 200, 300])); + + // With large values to test for potential overflow issues + let large_value = u64::MAX / 2; + let utxos = vec![large_value, large_value / 2]; + let selected = utxo_select_fixed(large_value, 1, &utxos); + assert_eq!(selected, Some(vec![large_value])); + } + + #[sdk_macros::test_all] + fn test_utxo_select_best() { + let utxos = vec![100, 200, 300, 400]; + + // Should find optimal solution + let selected = utxo_select_best(300, &utxos); + assert_eq!(selected, Some(vec![300])); + + // Should fallback to basic selection as no exact utxo set can be found + let selected: Option> = utxo_select_best(450, &utxos); + assert!(selected.is_some()); + assert_eq!(selected.unwrap().iter().sum::(), 600); + + // Should use all UTXOs as fallback when needed + let selected = utxo_select_best(950, &utxos); + assert_eq!(selected, Some(vec![100, 200, 300, 400])); + } + + #[sdk_macros::test_all] + fn test_utxo_select_in_range() { + let utxos = vec![50, 100, 200, 300, 400]; + + // Exact match + let selected = utxo_select_in_range(300, 0, 0, &utxos); + assert_eq!(selected, Some(vec![300])); + + // Within range + let selected = utxo_select_in_range(350, 50, 0, &utxos); + assert_eq!(selected, Some(vec![400])); + + // Multiple UTXOs needed + let selected = utxo_select_in_range(350, 0, 0, &utxos); + assert_eq!(selected, Some(vec![300, 50])); + + // With target count + let selected = utxo_select_in_range(250, 0, 2, &utxos); + assert_eq!(selected, Some(vec![200, 50])); + } + + #[sdk_macros::test_all] + fn test_utxo_select_success() { + let policy_asset = AssetId::from_slice(&[1; 32]).unwrap(); + let fee_asset = AssetId::from_slice(&[2; 32]).unwrap(); + + // Create wallet UTXOs with both policy and fee assets + let wallet_utxos = vec![ + InOut { + asset_id: policy_asset, + value: 100000000, + }, + InOut { + asset_id: policy_asset, + value: 200000000, + }, + InOut { + asset_id: fee_asset, + value: 50000000, + }, + InOut { + asset_id: fee_asset, + value: 80000000, + }, + ]; + + // Create server UTXOs (only policy asset) + let server_utxos = vec![ + InOut { + asset_id: policy_asset, + value: 150000000, + }, + InOut { + asset_id: policy_asset, + value: 250000000, + }, + ]; + + // User outputs (both assets) + let user_outputs = vec![ + InOut { + asset_id: policy_asset, + value: 150000000, + }, + InOut { + asset_id: fee_asset, + value: 20000000, + }, + ]; + + let req = UtxoSelectRequest { + policy_asset, + fee_asset, + price: 84896.5, + fixed_fee: 4000000, + wallet_utxos, + server_utxos, + user_outputs, + }; + + let result = utxo_select(req); + assert!(result.is_ok()); + + let selection = result.unwrap(); + + // Verify network fee is covered by server inputs + assert!(selection.network_fee.value > 0); + assert_eq!(selection.network_fee.asset_id, policy_asset); + + // Verify server fee is in fee_asset and reasonable + assert!(selection.server_fee.value >= 100); // at least fixed fee + assert_eq!(selection.server_fee.asset_id, fee_asset); + + // Verify all user outputs are present + assert_eq!(selection.user_outputs.len(), 2); + + // Check input/output balance + let mut input_sum_by_asset = BTreeMap::::new(); + let mut output_sum_by_asset = BTreeMap::::new(); + + // Sum all inputs + for input in selection + .user_inputs + .iter() + .chain(selection.client_inputs.iter()) + .chain(selection.server_inputs.iter()) + { + *input_sum_by_asset.entry(input.asset_id).or_default() += input.value; + } + + // Sum all outputs + for output in selection + .user_outputs + .iter() + .chain(selection.change_outputs.iter()) + .chain(std::iter::once(&selection.server_fee)) + .chain(selection.server_change.iter()) + .chain(selection.fee_change.iter()) + .chain(std::iter::once(&selection.network_fee)) + { + *output_sum_by_asset.entry(output.asset_id).or_default() += output.value; + } + + // Input and output sums should match for each asset + assert_eq!(input_sum_by_asset, output_sum_by_asset); + } + + #[sdk_macros::test_all] + fn test_utxo_select_error_cases() { + let policy_asset = AssetId::from_slice(&[1; 32]).unwrap(); + let fee_asset = AssetId::from_slice(&[2; 32]).unwrap(); + + // Base valid request + let valid_req = UtxoSelectRequest { + policy_asset, + fee_asset, + price: 84896.5, + fixed_fee: 4000000, + wallet_utxos: vec![ + InOut { + asset_id: policy_asset, + value: 1000, + }, + InOut { + asset_id: fee_asset, + value: 500, + }, + ], + server_utxos: vec![InOut { + asset_id: policy_asset, + value: 1500, + }], + user_outputs: vec![InOut { + asset_id: policy_asset, + value: 500, + }], + }; + + // Same asset for policy and fee - should error + let mut bad_req = valid_req.clone(); + bad_req.fee_asset = policy_asset; + assert!(utxo_select(bad_req).is_err()); + + // Zero price - should error + let mut bad_req = valid_req.clone(); + bad_req.price = 0.0; + assert!(utxo_select(bad_req).is_err()); + + // Zero fixed fee - should error + let mut bad_req = valid_req.clone(); + bad_req.fixed_fee = 0; + assert!(utxo_select(bad_req).is_err()); + + // Invalid server UTXO asset - should error + let mut bad_req = valid_req.clone(); + bad_req.server_utxos = vec![InOut { + asset_id: fee_asset, + value: 1500, + }]; + assert!(utxo_select(bad_req).is_err()); + + // Insufficient fee assets - should error + let mut bad_req = valid_req.clone(); + bad_req.wallet_utxos = vec![ + InOut { + asset_id: policy_asset, + value: 1000, + }, + InOut { + asset_id: fee_asset, + value: 10, + }, // Too small + ]; + let result = utxo_select(bad_req); + assert!(result.is_err()); + } + + #[sdk_macros::test_all] + fn test_utxo_select_with_change() { + let policy_asset = AssetId::from_slice(&[1; 32]).unwrap(); + let fee_asset = AssetId::from_slice(&[2; 32]).unwrap(); + + // Create a scenario where change is needed + let wallet_utxos = vec![ + InOut { + asset_id: policy_asset, + value: 50000000, + }, + InOut { + asset_id: fee_asset, + value: 20000000, + }, + ]; + + let server_utxos = vec![ + InOut { + asset_id: policy_asset, + value: 10000000, + }, + InOut { + asset_id: policy_asset, + value: 20000000, + }, + ]; + + let user_outputs = vec![InOut { + asset_id: policy_asset, + value: 30000000, + }]; + + let req = UtxoSelectRequest { + policy_asset, + fee_asset, + price: 84896.5, + fixed_fee: 4000000, + wallet_utxos, + server_utxos, + user_outputs, + }; + + let result = utxo_select(req); + assert!(result.is_ok()); + + let selection = result.unwrap(); + + // Either policy asset change or fee asset change should exist + assert!(selection.fee_change.is_some() || !selection.change_outputs.is_empty()); + + // Verify change amounts are reasonable + if let Some(fee_change) = &selection.fee_change { + assert_eq!(fee_change.asset_id, fee_asset); + assert!(fee_change.value > 0); + } + + // Check that we're not wasting fees unnecessarily + assert!(selection.cost <= selection.server_fee.value); + } +} diff --git a/lib/core/src/persist/asset_metadata.rs b/lib/core/src/persist/asset_metadata.rs index 0dd3a87..87d55ef 100644 --- a/lib/core/src/persist/asset_metadata.rs +++ b/lib/core/src/persist/asset_metadata.rs @@ -13,8 +13,8 @@ impl Persister { if let Some(asset_metadata) = asset_metadata { for am in asset_metadata { con.execute( - "INSERT INTO asset_metadata (asset_id, name, ticker, precision) VALUES (?, ?, ?, ?)", - (am.asset_id, am.name, am.ticker, am.precision), + "INSERT INTO asset_metadata (asset_id, name, ticker, precision, fiat_id) VALUES (?, ?, ?, ?, ?)", + (am.asset_id, am.name, am.ticker, am.precision, am.fiat_id), )?; } } @@ -28,7 +28,8 @@ impl Persister { "SELECT asset_id, name, ticker, - precision + precision, + fiat_id FROM asset_metadata", )?; let asset_metadata: Vec = stmt @@ -44,7 +45,8 @@ impl Persister { "SELECT asset_id, name, ticker, - precision + precision, + fiat_id FROM asset_metadata WHERE asset_id = ?", )?; @@ -59,6 +61,7 @@ impl Persister { name: row.get(1)?, ticker: row.get(2)?, precision: row.get(3)?, + fiat_id: row.get(4)?, }) } } diff --git a/lib/core/src/persist/migrations.rs b/lib/core/src/persist/migrations.rs index a49da3f..1f99f2a 100644 --- a/lib/core/src/persist/migrations.rs +++ b/lib/core/src/persist/migrations.rs @@ -25,6 +25,11 @@ pub(crate) fn current_migrations(network: LiquidNetwork) -> Vec<&'static str> { ('5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225', 'Regtest Bitcoin', 'BTC', 8, 1); " }; + let update_asset_metadata_fiat_id = match network { + LiquidNetwork::Mainnet => "UPDATE asset_metadata SET fiat_id = 'USD' WHERE asset_id = 'ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2';", + LiquidNetwork::Testnet => "UPDATE asset_metadata SET fiat_id = 'USD' WHERE asset_id = 'b612eb46313a2cd6ebabd8b7a8eed5696e29898b87a43bff41c94f51acef9d73';", + LiquidNetwork::Regtest => ";", + }; vec![ "CREATE TABLE IF NOT EXISTS receive_swaps ( id TEXT NOT NULL PRIMARY KEY, @@ -315,5 +320,8 @@ pub(crate) fn current_migrations(network: LiquidNetwork) -> Vec<&'static str> { WHERE id = NEW.id; END; ", + "ALTER TABLE asset_metadata ADD COLUMN fiat_id TEXT;", + update_asset_metadata_fiat_id, + "ALTER TABLE payment_details ADD COLUMN asset_fees INTEGER;", ] } diff --git a/lib/core/src/persist/mod.rs b/lib/core/src/persist/mod.rs index 4d18fe3..121e311 100644 --- a/lib/core/src/persist/mod.rs +++ b/lib/core/src/persist/mod.rs @@ -341,15 +341,17 @@ impl Persister { destination, description, lnurl_info_json, - bip353_address + bip353_address, + asset_fees ) - VALUES (?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (tx_id) DO UPDATE SET {destination_update} description = COALESCE(excluded.description, description), lnurl_info_json = COALESCE(excluded.lnurl_info_json, lnurl_info_json), - bip353_address = COALESCE(excluded.bip353_address, bip353_address) + bip353_address = COALESCE(excluded.bip353_address, bip353_address), + asset_fees = COALESCE(excluded.asset_fees, asset_fees) " ), ( @@ -361,6 +363,7 @@ impl Persister { .as_ref() .map(|info| serde_json::to_string(&info).ok()), &payment_tx_details.bip353_address, + &payment_tx_details.asset_fees, ), )?; Ok(()) @@ -389,7 +392,7 @@ impl Persister { pub(crate) fn get_payment_details(&self, tx_id: &str) -> Result> { let con = self.get_connection()?; let mut stmt = con.prepare( - "SELECT destination, description, lnurl_info_json, bip353_address + "SELECT destination, description, lnurl_info_json, bip353_address, asset_fees FROM payment_details WHERE tx_id = ?", )?; @@ -398,6 +401,7 @@ impl Persister { let description = row.get(1)?; let maybe_lnurl_info_json: Option = row.get(2)?; let maybe_bip353_address = row.get(3)?; + let maybe_asset_fees = row.get(4)?; Ok(PaymentTxDetails { tx_id: tx_id.to_string(), destination, @@ -405,6 +409,7 @@ impl Persister { lnurl_info: maybe_lnurl_info_json .and_then(|info| serde_json::from_str::(&info).ok()), bip353_address: maybe_bip353_address, + asset_fees: maybe_asset_fees, }) }); Ok(res.ok()) @@ -500,6 +505,7 @@ impl Persister { pd.description, pd.lnurl_info_json, pd.bip353_address, + pd.asset_fees, am.name, am.ticker, am.precision @@ -622,10 +628,11 @@ impl Persister { let maybe_payment_details_lnurl_info: Option = maybe_payment_details_lnurl_info_json.and_then(|info| serde_json::from_str(&info).ok()); let maybe_payment_details_bip353_address: Option = row.get(55)?; + let maybe_payment_details_asset_fees: Option = row.get(56)?; - let maybe_asset_metadata_name: Option = row.get(56)?; - let maybe_asset_metadata_ticker: Option = row.get(57)?; - let maybe_asset_metadata_precision: Option = row.get(58)?; + let maybe_asset_metadata_name: Option = row.get(57)?; + let maybe_asset_metadata_ticker: Option = row.get(58)?; + let maybe_asset_metadata_precision: Option = row.get(59)?; let (swap, payment_type) = match maybe_receive_swap_id { Some(receive_swap_id) => { @@ -844,12 +851,21 @@ impl Persister { name: name.clone(), ticker: ticker.clone(), precision, + fiat_id: None, }; + let (amount, fees) = + maybe_payment_details_asset_fees.map_or((amount, None), |fees| { + ( + amount.saturating_sub(fees), + Some(asset_metadata.amount_from_sat(fees)), + ) + }); Some(AssetInfo { name, ticker, amount: asset_metadata.amount_from_sat(amount), + fees, }) } _ => None, diff --git a/lib/core/src/persist/model.rs b/lib/core/src/persist/model.rs index 7e953ae..ba23b37 100644 --- a/lib/core/src/persist/model.rs +++ b/lib/core/src/persist/model.rs @@ -7,4 +7,5 @@ pub(crate) struct PaymentTxDetails { pub(crate) description: Option, pub(crate) lnurl_info: Option, pub(crate) bip353_address: Option, + pub(crate) asset_fees: Option, } diff --git a/lib/core/src/sdk.rs b/lib/core/src/sdk.rs index dbf88dd..685456e 100644 --- a/lib/core/src/sdk.rs +++ b/lib/core/src/sdk.rs @@ -37,6 +37,7 @@ use crate::error::SdkError; use crate::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription}; use crate::model::PaymentState::*; use crate::model::Signer; +use crate::payjoin::{side_swap::SideSwapPayjoinService, PayjoinService}; use crate::receive_swap::ReceiveSwapHandler; use crate::send_swap::SendSwapHandler; use crate::swapper::SubscriptionHandler; @@ -77,6 +78,7 @@ pub struct LiquidSdkBuilder { bitcoin_chain_service: Option>, liquid_chain_service: Option>, onchain_wallet: Option>, + payjoin_service: Option>, persister: Option>, recoverer: Option>, rest_client: Option>, @@ -100,6 +102,7 @@ impl LiquidSdkBuilder { bitcoin_chain_service: None, liquid_chain_service: None, onchain_wallet: None, + payjoin_service: None, persister: None, recoverer: None, rest_client: None, @@ -135,6 +138,11 @@ impl LiquidSdkBuilder { self } + pub fn payjoin_service(&mut self, payjoin_service: Arc) -> &mut Self { + self.payjoin_service = Some(payjoin_service.clone()); + self + } + pub fn persister(&mut self, persister: Arc) -> &mut Self { self.persister = Some(persister.clone()); self @@ -307,6 +315,17 @@ impl LiquidSdkBuilder { bitcoin_chain_service.clone(), )?); + let payjoin_service = match self.payjoin_service.clone() { + Some(payjoin_service) => payjoin_service, + None => Arc::new(SideSwapPayjoinService::new( + self.config.clone(), + self.breez_server.clone(), + persister.clone(), + onchain_wallet.clone(), + rest_client.clone(), + )), + }; + let buy_bitcoin_service = Arc::new(BuyBitcoinService::new( self.config.clone(), self.breez_server.clone(), @@ -334,6 +353,7 @@ impl LiquidSdkBuilder { receive_swap_handler, sync_service, chain_swap_handler, + payjoin_service, buy_bitcoin_service, external_input_parsers, }); @@ -361,6 +381,7 @@ pub struct LiquidSdk { pub(crate) sync_service: Option>, pub(crate) receive_swap_handler: ReceiveSwapHandler, pub(crate) chain_swap_handler: Arc, + pub(crate) payjoin_service: Arc, pub(crate) buy_bitcoin_service: Arc, pub(crate) external_input_parsers: Vec, } @@ -1135,7 +1156,11 @@ impl LiquidSdk { /// # Returns /// Returns a [PrepareSendResponse] containing: /// * `destination` - the parsed destination, of type [SendDestination] - /// * `fees_sat` - the additional fees which will be paid by the sender + /// * `fees_sat` - the optional estimated fee in satoshi. Is set when there is Bitcoin + /// available to pay fees. When not set, there are asset fees available to pay fees. + /// * `estimated_asset_fees` - the optional estimated fee in the asset. Is set when + /// [PayAmount::Asset::estimate_asset_fees] is set to `true`, the Payjoin service accepts + /// this asset to pay fees and there are funds available in this asset to pay fees. pub async fn prepare_send_payment( &self, req: &PrepareSendRequest, @@ -1144,6 +1169,7 @@ impl LiquidSdk { let get_info_res = self.get_info().await?; let fees_sat; + let estimated_asset_fees; let receiver_amount_sat; let asset_id; let payment_destination; @@ -1167,6 +1193,7 @@ impl LiquidSdk { PayAmount::Asset { asset_id, receiver_amount: amount, + estimate_asset_fees: None, } } } @@ -1192,7 +1219,12 @@ impl LiquidSdk { } ); - (asset_id, receiver_amount_sat, fees_sat) = match amount { + ( + asset_id, + receiver_amount_sat, + fees_sat, + estimated_asset_fees, + ) = match amount { PayAmount::Drain => { ensure_sdk!( get_info_res.wallet_info.pending_receive_sat == 0 @@ -1210,7 +1242,8 @@ impl LiquidSdk { ( self.config.lbtc_asset_id(), drain_amount_sat, - drain_fees_sat, + Some(drain_fees_sat), + None, ) } PayAmount::Bitcoin { @@ -1224,26 +1257,47 @@ impl LiquidSdk { &asset_id, ) .await?; - (asset_id, receiver_amount_sat, fees_sat) + (asset_id, receiver_amount_sat, Some(fees_sat), None) } PayAmount::Asset { asset_id, receiver_amount, + estimate_asset_fees, } => { + let estimate_asset_fees = estimate_asset_fees.unwrap_or(false); let asset_metadata = self.persister.get_asset_metadata(&asset_id)?.ok_or( PaymentError::AssetError { err: format!("Asset {asset_id} is not supported"), }, )?; let receiver_amount_sat = asset_metadata.amount_to_sat(receiver_amount); - let fees_sat = self + let fees_sat_res = self .estimate_onchain_tx_or_drain_tx_fee( receiver_amount_sat, &liquid_address_data.address, &asset_id, ) - .await?; - (asset_id, receiver_amount_sat, fees_sat) + .await; + let asset_fees = if estimate_asset_fees { + self.payjoin_service + .estimate_payjoin_tx_fee(&asset_id, receiver_amount_sat) + .await + .inspect_err(|e| debug!("Error estimating payjoin tx: {e}")) + .ok() + } else { + None + }; + let (fees_sat, asset_fees) = match (fees_sat_res, asset_fees) { + (Ok(fees_sat), _) => (Some(fees_sat), asset_fees), + (Err(e), Some(asset_fees)) => { + debug!( + "Error estimating onchain tx, but returning payjoin fees: {e}" + ); + (None, Some(asset_fees)) + } + (Err(e), None) => return Err(e), + }; + (asset_id, receiver_amount_sat, fees_sat, asset_fees) } }; @@ -1281,6 +1335,7 @@ impl LiquidSdk { .await? .map(|(address, _)| address); asset_id = self.config.lbtc_asset_id(); + estimated_asset_fees = None; (receiver_amount_sat, fees_sat, payment_destination) = match (mrh_address.clone(), req.amount.clone()) { (Some(lbtc_address), Some(PayAmount::Drain)) => { @@ -1303,7 +1358,7 @@ impl LiquidSdk { }, bip353_address: None, }; - (drain_amount_sat, drain_fees_sat, payment_destination) + (drain_amount_sat, Some(drain_fees_sat), payment_destination) } (Some(lbtc_address), _) => { // The BOLT11 invoice has an MRH but no drain is requested, @@ -1317,7 +1372,7 @@ impl LiquidSdk { .await?; ( invoice_amount_sat, - fees_sat, + Some(fees_sat), SendDestination::Bolt11 { invoice, bip353_address: None, @@ -1334,7 +1389,7 @@ impl LiquidSdk { let fees_sat = boltz_fees_total + lockup_fees_sat; ( invoice_amount_sat, - fees_sat, + Some(fees_sat), SendDestination::Bolt11 { invoice, bip353_address: None, @@ -1371,7 +1426,8 @@ impl LiquidSdk { .estimate_lockup_tx_or_drain_tx_fee(receiver_amount_sat + boltz_fees_total) .await?; asset_id = self.config.lbtc_asset_id(); - fees_sat = boltz_fees_total + lockup_fees_sat; + fees_sat = Some(boltz_fees_total + lockup_fees_sat); + estimated_asset_fees = None; payment_destination = SendDestination::Bolt12 { offer, @@ -1394,6 +1450,7 @@ impl LiquidSdk { Ok(PrepareSendResponse { destination: payment_destination, fees_sat, + estimated_asset_fees, }) } @@ -1428,6 +1485,7 @@ impl LiquidSdk { let PrepareSendResponse { fees_sat, destination: payment_destination, + .. } = &req.prepare_response; match payment_destination { @@ -1435,6 +1493,7 @@ impl LiquidSdk { address_data: liquid_address_data, bip353_address, } => { + let asset_pay_fees = req.use_asset_fees.unwrap_or_default(); let Some(amount_sat) = liquid_address_data.amount_sat else { return Err(PaymentError::AmountMissing { err: "Amount must be set when paying to a Liquid address".to_string(), @@ -1466,9 +1525,16 @@ impl LiquidSdk { *fees_sat, asset_id, )?; - let mut response = self - .pay_liquid(liquid_address_data.clone(), amount_sat, *fees_sat, true) - .await?; + + let mut response = if asset_pay_fees { + self.pay_liquid_payjoin(liquid_address_data.clone(), amount_sat) + .await? + } else { + let fees_sat = fees_sat.ok_or(PaymentError::InsufficientFunds)?; + self.pay_liquid(liquid_address_data.clone(), amount_sat, fees_sat, true) + .await? + }; + self.insert_bip353_payment_details(bip353_address, &mut response)?; Ok(response) } @@ -1476,7 +1542,8 @@ impl LiquidSdk { invoice, bip353_address, } => { - let mut response = self.pay_bolt11_invoice(&invoice.bolt11, *fees_sat).await?; + let fees_sat = fees_sat.ok_or(PaymentError::InsufficientFunds)?; + let mut response = self.pay_bolt11_invoice(&invoice.bolt11, fees_sat).await?; self.insert_bip353_payment_details(bip353_address, &mut response)?; Ok(response) } @@ -1485,12 +1552,13 @@ impl LiquidSdk { receiver_amount_sat, bip353_address, } => { + let fees_sat = fees_sat.ok_or(PaymentError::InsufficientFunds)?; let bolt12_invoice = self .swapper .get_bolt12_invoice(&offer.offer, *receiver_amount_sat) .await?; let mut response = self - .pay_bolt12_invoice(offer, *receiver_amount_sat, &bolt12_invoice, *fees_sat) + .pay_bolt12_invoice(offer, *receiver_amount_sat, &bolt12_invoice, fees_sat) .await?; self.insert_bip353_payment_details(bip353_address, &mut response)?; Ok(response) @@ -1514,6 +1582,7 @@ impl LiquidSdk { description: None, lnurl_info: None, bip353_address: bip353_address.clone(), + asset_fees: None, })?; // Get the payment with the bip353_address details if let Some(payment) = self.persister.get_payment(tx_id)? { @@ -1608,7 +1677,7 @@ impl LiquidSdk { .await } - /// Performs a Send Payment by doing an onchain tx to a L-BTC address + /// Performs a Send Payment by doing an onchain tx to a Liquid address async fn pay_liquid( &self, address_data: LiquidAddressData, @@ -1646,7 +1715,7 @@ impl LiquidSdk { ensure_sdk!(tx_fees_sat <= fees_sat, PaymentError::InvalidOrExpiredFees); info!( - "Built onchain L-BTC tx with receiver_amount_sat = {receiver_amount_sat}, fees_sat = {fees_sat} and txid = {tx_id}" + "Built onchain Liquid tx with receiver_amount_sat = {receiver_amount_sat}, fees_sat = {fees_sat} and txid = {tx_id}" ); let tx_id = self.liquid_chain_service.broadcast(&tx).await?.to_string(); @@ -1685,6 +1754,87 @@ impl LiquidSdk { name: am.name.clone(), ticker: am.ticker.clone(), amount: am.amount_from_sat(receiver_amount_sat), + fees: None, + }); + let payment_details = PaymentDetails::Liquid { + asset_id, + destination, + description: description.unwrap_or("Liquid transfer".to_string()), + asset_info, + lnurl_info: None, + bip353_address: None, + }; + + Ok(SendPaymentResponse { + payment: Payment::from_tx_data(tx_data, None, payment_details), + }) + } + + /// Performs a Send Payment by doing a payjoin tx to a Liquid address + async fn pay_liquid_payjoin( + &self, + address_data: LiquidAddressData, + receiver_amount_sat: u64, + ) -> Result { + let destination = address_data + .to_uri() + .unwrap_or(address_data.address.clone()); + let Some(asset_id) = address_data.asset_id else { + return Err(PaymentError::asset_error( + "Asset must be set when paying to a Liquid address", + )); + }; + + let (tx, asset_fees) = self + .payjoin_service + .build_payjoin_tx(&address_data.address, &asset_id, receiver_amount_sat) + .await + .inspect_err(|e| error!("Error building payjoin tx: {e}"))?; + let tx_id = tx.txid().to_string(); + let fees_sat = tx.all_fees().values().sum::(); + + info!( + "Built payjoin Liquid tx with receiver_amount_sat = {receiver_amount_sat}, asset_fees = {asset_fees}, fees_sat = {fees_sat} and txid = {tx_id}" + ); + + let tx_id = self.liquid_chain_service.broadcast(&tx).await?.to_string(); + + // We insert a pseudo-tx in case LWK fails to pick up the new mempool tx for a while + // This makes the tx known to the SDK (get_info, list_payments) instantly + let tx_data = PaymentTxData { + tx_id: tx_id.clone(), + timestamp: Some(utils::now()), + amount: receiver_amount_sat + asset_fees, + fees_sat, + payment_type: PaymentType::Send, + is_confirmed: false, + unblinding_data: None, + asset_id: asset_id.clone(), + }; + + let description = address_data.message; + + self.persister.insert_or_update_payment( + tx_data.clone(), + Some(PaymentTxDetails { + tx_id: tx_id.clone(), + destination: destination.clone(), + description: description.clone(), + asset_fees: Some(asset_fees), + ..Default::default() + }), + false, + )?; + self.emit_payment_updated(Some(tx_id)).await?; // Emit Pending event + + let asset_info = self + .persister + .get_asset_metadata(&asset_id)? + .map(|ref am| AssetInfo { + name: am.name.clone(), + ticker: am.ticker.clone(), + amount: am.amount_from_sat(receiver_amount_sat), + fees: Some(am.amount_from_sat(asset_fees)), }); let payment_details = PaymentDetails::Liquid { asset_id, @@ -3513,10 +3663,13 @@ impl LiquidSdk { } destination => destination, }; + let fees_sat = prepare_response + .fees_sat + .ok_or(PaymentError::InsufficientFunds)?; Ok(PrepareLnUrlPayResponse { destination, - fees_sat: prepare_response.fees_sat, + fees_sat, data: req.data, comment: req.comment, success_action: data.success_action, @@ -3546,8 +3699,10 @@ impl LiquidSdk { .send_payment(&SendPaymentRequest { prepare_response: PrepareSendResponse { destination: prepare_response.destination.clone(), - fees_sat: prepare_response.fees_sat, + fees_sat: Some(prepare_response.fees_sat), + estimated_asset_fees: None, }, + use_asset_fees: None, }) .await .map_err(|e| LnUrlPayError::Generic { err: e.to_string() })? @@ -3633,6 +3788,7 @@ impl LiquidSdk { lnurl_withdraw_endpoint: None, }), bip353_address: None, + asset_fees: None, })?; // Get the payment with the lnurl_info details payment = self.persister.get_payment(&tx_id)?.unwrap_or(payment); @@ -3701,6 +3857,7 @@ impl LiquidSdk { ..Default::default() }), bip353_address: None, + asset_fees: None, })?; } } diff --git a/lib/core/src/send_swap.rs b/lib/core/src/send_swap.rs index 6b51755..7042706 100644 --- a/lib/core/src/send_swap.rs +++ b/lib/core/src/send_swap.rs @@ -320,6 +320,7 @@ impl SendSwapHandler { description, lnurl_info: Some(lnurl_info), bip353_address, + asset_fees: None, })?; return Ok(true); } diff --git a/lib/core/src/sync/model/data.rs b/lib/core/src/sync/model/data.rs index da79e84..38021bd 100644 --- a/lib/core/src/sync/model/data.rs +++ b/lib/core/src/sync/model/data.rs @@ -305,6 +305,7 @@ pub(crate) struct PaymentDetailsSyncData { pub(crate) description: Option, pub(crate) lnurl_info: Option, pub(crate) bip353_address: Option, + pub(crate) asset_fees: Option, } impl PaymentDetailsSyncData { @@ -315,6 +316,7 @@ impl PaymentDetailsSyncData { "description" => clone_if_set(&mut self.description, &other.description), "lnurl_info" => clone_if_set(&mut self.lnurl_info, &other.lnurl_info), "bip353_address" => clone_if_set(&mut self.bip353_address, &other.bip353_address), + "asset_fees" => self.asset_fees = other.asset_fees, _ => continue, } } @@ -329,6 +331,7 @@ impl From for PaymentDetailsSyncData { description: value.description, lnurl_info: value.lnurl_info, bip353_address: value.bip353_address, + asset_fees: value.asset_fees, } } } @@ -341,6 +344,7 @@ impl From for PaymentTxDetails { description: val.description, lnurl_info: val.lnurl_info, bip353_address: val.bip353_address, + asset_fees: val.asset_fees, } } } diff --git a/lib/core/src/sync/model/mod.rs b/lib/core/src/sync/model/mod.rs index 5dc326e..f92d4b8 100644 --- a/lib/core/src/sync/model/mod.rs +++ b/lib/core/src/sync/model/mod.rs @@ -19,7 +19,7 @@ pub(crate) mod data; const MESSAGE_PREFIX: &[u8; 13] = b"realtimesync:"; lazy_static! { - static ref CURRENT_SCHEMA_VERSION: Version = Version::parse("0.4.0").unwrap(); + static ref CURRENT_SCHEMA_VERSION: Version = Version::parse("0.5.0").unwrap(); } #[derive(Copy, Clone)] diff --git a/lib/core/src/test_utils/wallet.rs b/lib/core/src/test_utils/wallet.rs index ba990af..a950b27 100644 --- a/lib/core/src/test_utils/wallet.rs +++ b/lib/core/src/test_utils/wallet.rs @@ -1,6 +1,6 @@ #![cfg(test)] -use std::{collections::HashMap, str::FromStr}; +use std::{collections::HashMap, str::FromStr, sync::Mutex}; use crate::{ error::PaymentError, @@ -18,15 +18,16 @@ use lwk_wollet::{ self, bip32::{DerivationPath, Xpriv, Xpub}, }, - elements::{hex::ToHex, Address, Transaction, Txid}, + elements::{hex::ToHex, pset::PartiallySignedTransaction, Address, AssetId, Transaction, Txid}, elements_miniscript::{slip77::MasterBlindingKey, ToPublicKey as _}, secp256k1::{All, Message}, - WalletTx, + WalletTx, WalletTxOut, }; use sdk_common::utils::Arc; pub(crate) struct MockWallet { signer: SdkLwkSigner, + utxos: Mutex>, } lazy_static! { @@ -38,7 +39,15 @@ lazy_static! { impl MockWallet { pub(crate) fn new(user_signer: Arc>) -> Result { let signer = crate::signer::SdkLwkSigner::new(user_signer.clone())?; - Ok(Self { signer }) + Ok(Self { + signer, + utxos: Mutex::new(vec![]), + }) + } + + pub(crate) fn set_utxos(&self, utxos: Vec) -> &Self { + *self.utxos.lock().unwrap() = utxos; + self } } @@ -52,6 +61,10 @@ impl OnchainWallet for MockWallet { Ok(Default::default()) } + async fn asset_utxos(&self, _asset_id: &AssetId) -> Result, PaymentError> { + Ok(self.utxos.lock().unwrap().clone()) + } + async fn build_tx( &self, _fee_rate: Option, @@ -81,10 +94,21 @@ impl OnchainWallet for MockWallet { Ok(TEST_LIQUID_TX.clone()) } + async fn sign_pset( + &self, + _pset: PartiallySignedTransaction, + ) -> Result { + Ok(TEST_LIQUID_TX.clone()) + } + async fn next_unused_address(&self) -> Result { Ok(TEST_P2TR_ADDR.clone()) } + async fn next_unused_change_address(&self) -> Result { + Ok(TEST_P2TR_ADDR.clone()) + } + async fn tip(&self) -> u32 { 0 } diff --git a/lib/core/src/wallet.rs b/lib/core/src/wallet.rs index a493478..f60cae5 100644 --- a/lib/core/src/wallet.rs +++ b/lib/core/src/wallet.rs @@ -8,11 +8,12 @@ use log::{debug, info, warn}; use lwk_common::Signer as LwkSigner; use lwk_common::{singlesig_desc, Singlesig}; use lwk_wollet::asyncr::{EsploraClient, EsploraClientBuilder}; -use lwk_wollet::elements::{AssetId, Txid}; +use lwk_wollet::elements::hex::ToHex; +use lwk_wollet::elements::pset::PartiallySignedTransaction; +use lwk_wollet::elements::{Address, AssetId, OutPoint, Transaction, TxOut, Txid}; use lwk_wollet::secp256k1::Message; use lwk_wollet::{ - elements::{hex::ToHex, Address, Transaction}, - ElementsNetwork, FsPersister, NoPersist, WalletTx, Wollet, WolletDescriptor, + ElementsNetwork, FsPersister, NoPersist, WalletTx, WalletTxOut, Wollet, WolletDescriptor, }; use maybe_sync::{MaybeSend, MaybeSync}; use sdk_common::bitcoin::hashes::{sha256, Hash}; @@ -44,6 +45,9 @@ pub trait OnchainWallet: MaybeSend + MaybeSync { /// List all transactions in the wallet mapped by tx id async fn transactions_by_tx_id(&self) -> Result, PaymentError>; + /// List all utxos in the wallet for a given asset + async fn asset_utxos(&self, asset: &AssetId) -> Result, PaymentError>; + /// Build a transaction to send funds to a recipient async fn build_tx( &self, @@ -78,9 +82,18 @@ pub trait OnchainWallet: MaybeSend + MaybeSync { amount_sat: u64, ) -> Result; + /// Sign a partially signed transaction + async fn sign_pset( + &self, + pset: PartiallySignedTransaction, + ) -> Result; + /// Get the next unused address in the wallet async fn next_unused_address(&self) -> Result; + /// Get the next unused change address in the wallet + async fn next_unused_change_address(&self) -> Result; + /// Get the current tip of the blockchain the wallet is aware of async fn tip(&self) -> u32; @@ -272,6 +285,18 @@ impl LiquidOnchainWallet { .map_err(|e| anyhow!("Invalid descriptor: {e}"))?; Ok(descriptor_str.parse()?) } + + async fn get_txout(&self, wallet: &Wollet, outpoint: &OutPoint) -> Result { + let wallet_tx = wallet + .transaction(&outpoint.txid)? + .ok_or(anyhow!("Transaction not found"))?; + let tx_out = wallet_tx + .tx + .output + .get(outpoint.vout as usize) + .ok_or(anyhow!("Output not found"))?; + Ok(tx_out.clone()) + } } #[sdk_macros::async_trait] @@ -295,6 +320,17 @@ impl OnchainWallet for LiquidOnchainWallet { Ok(tx_map) } + async fn asset_utxos(&self, asset: &AssetId) -> Result, PaymentError> { + Ok(self + .wallet + .lock() + .await + .utxos()? + .into_iter() + .filter(|utxo| &utxo.unblinded.asset == asset) + .collect()) + } + /// Build a transaction to send funds to a recipient async fn build_tx( &self, @@ -401,6 +437,50 @@ impl OnchainWallet for LiquidOnchainWallet { } } + async fn sign_pset( + &self, + mut pset: PartiallySignedTransaction, + ) -> Result { + let lwk_wollet = self.wallet.lock().await; + + // Get the tx_out for each input and add the rangeproof/witness utxo + for input in pset.inputs_mut().iter_mut() { + let tx_out_res = self + .get_txout( + &lwk_wollet, + &OutPoint { + txid: input.previous_txid, + vout: input.previous_output_index, + }, + ) + .await; + if let Ok(mut tx_out) = tx_out_res { + input.in_utxo_rangeproof = tx_out.witness.rangeproof.take(); + input.witness_utxo = Some(tx_out); + } + } + + lwk_wollet.add_details(&mut pset)?; + + self.signer + .sign(&mut pset) + .map_err(|e| PaymentError::Generic { + err: format!("Failed to sign transaction: {e:?}"), + })?; + + // Set the final script witness for each input adding the signature and any missing public key + for input in pset.inputs_mut() { + if let Some((public_key, input_sign)) = input.partial_sigs.iter().next() { + input.final_script_witness = Some(vec![input_sign.clone(), public_key.to_bytes()]); + } + } + + let tx = pset.extract_tx().map_err(|e| PaymentError::Generic { + err: format!("Failed to extract transaction: {e:?}"), + })?; + Ok(tx) + } + /// Get the next unused address in the wallet async fn next_unused_address(&self) -> Result { let tip = self.tip().await; @@ -432,6 +512,13 @@ impl OnchainWallet for LiquidOnchainWallet { Ok(address) } + /// Get the next unused change address in the wallet + async fn next_unused_change_address(&self) -> Result { + let address = self.wallet.lock().await.change(None)?.address().clone(); + + Ok(address) + } + /// Get the current tip of the blockchain the wallet is aware of async fn tip(&self) -> u32 { self.wallet.lock().await.tip().height() diff --git a/lib/wasm/src/model.rs b/lib/wasm/src/model.rs index 6577ff6..d8d71eb 100644 --- a/lib/wasm/src/model.rs +++ b/lib/wasm/src/model.rs @@ -314,6 +314,7 @@ pub struct Config { pub use_default_external_input_parsers: bool, pub onchain_fee_rate_leeway_sat_per_vbyte: Option, pub asset_metadata: Option>, + pub sideswap_api_key: Option, } #[derive(Clone)] @@ -443,12 +444,14 @@ pub enum SendDestination { #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::PrepareSendResponse)] pub struct PrepareSendResponse { pub destination: SendDestination, - pub fees_sat: u64, + pub fees_sat: Option, + pub estimated_asset_fees: Option, } #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::SendPaymentRequest)] pub struct SendPaymentRequest { pub prepare_response: PrepareSendResponse, + pub use_asset_fees: Option, } #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::SendPaymentResponse)] @@ -464,6 +467,7 @@ pub enum PayAmount { Asset { asset_id: String, receiver_amount: f64, + estimate_asset_fees: Option, }, Drain, } @@ -655,6 +659,7 @@ pub struct AssetMetadata { pub name: String, pub ticker: String, pub precision: u8, + pub fiat_id: Option, } #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::AssetInfo)] @@ -662,6 +667,7 @@ pub struct AssetInfo { pub name: String, pub ticker: String, pub amount: f64, + pub fees: Option, } #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::PaymentDetails)] diff --git a/packages/dart/lib/src/frb_generated.dart b/packages/dart/lib/src/frb_generated.dart index 54e150c..d392e69 100644 --- a/packages/dart/lib/src/frb_generated.dart +++ b/packages/dart/lib/src/frb_generated.dart @@ -1476,11 +1476,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { AssetInfo dco_decode_asset_info(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 3) throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + if (arr.length != 4) throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); return AssetInfo( name: dco_decode_String(arr[0]), ticker: dco_decode_String(arr[1]), amount: dco_decode_f_64(arr[2]), + fees: dco_decode_opt_box_autoadd_f_64(arr[3]), ); } @@ -1488,12 +1489,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { AssetMetadata dco_decode_asset_metadata(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 4) throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + if (arr.length != 5) throw Exception('unexpected arr length: expect 5 but see ${arr.length}'); return AssetMetadata( assetId: dco_decode_String(arr[0]), name: dco_decode_String(arr[1]), ticker: dco_decode_String(arr[2]), precision: dco_decode_u_8(arr[3]), + fiatId: dco_decode_opt_String(arr[4]), ); } @@ -1928,7 +1930,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { Config dco_decode_config(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 13) throw Exception('unexpected arr length: expect 13 but see ${arr.length}'); + if (arr.length != 14) throw Exception('unexpected arr length: expect 14 but see ${arr.length}'); return Config( liquidExplorer: dco_decode_blockchain_explorer(arr[0]), bitcoinExplorer: dco_decode_blockchain_explorer(arr[1]), @@ -1943,6 +1945,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { useDefaultExternalInputParsers: dco_decode_bool(arr[10]), onchainFeeRateLeewaySatPerVbyte: dco_decode_opt_box_autoadd_u_32(arr[11]), assetMetadata: dco_decode_opt_list_asset_metadata(arr[12]), + sideswapApiKey: dco_decode_opt_String(arr[13]), ); } @@ -2724,7 +2727,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { case 0: return PayAmount_Bitcoin(receiverAmountSat: dco_decode_u_64(raw[1])); case 1: - return PayAmount_Asset(assetId: dco_decode_String(raw[1]), receiverAmount: dco_decode_f_64(raw[2])); + return PayAmount_Asset( + assetId: dco_decode_String(raw[1]), + receiverAmount: dco_decode_f_64(raw[2]), + estimateAssetFees: dco_decode_opt_box_autoadd_bool(raw[3]), + ); case 2: return PayAmount_Drain(); default: @@ -3015,10 +3022,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { PrepareSendResponse dco_decode_prepare_send_response(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 2) throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + if (arr.length != 3) throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); return PrepareSendResponse( destination: dco_decode_send_destination(arr[0]), - feesSat: dco_decode_u_64(arr[1]), + feesSat: dco_decode_opt_box_autoadd_u_64(arr[1]), + estimatedAssetFees: dco_decode_opt_box_autoadd_f_64(arr[2]), ); } @@ -3220,8 +3228,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SendPaymentRequest dco_decode_send_payment_request(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 1) throw Exception('unexpected arr length: expect 1 but see ${arr.length}'); - return SendPaymentRequest(prepareResponse: dco_decode_prepare_send_response(arr[0])); + if (arr.length != 2) throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return SendPaymentRequest( + prepareResponse: dco_decode_prepare_send_response(arr[0]), + useAssetFees: dco_decode_opt_box_autoadd_bool(arr[1]), + ); } @protected @@ -3503,7 +3514,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_name = sse_decode_String(deserializer); var var_ticker = sse_decode_String(deserializer); var var_amount = sse_decode_f_64(deserializer); - return AssetInfo(name: var_name, ticker: var_ticker, amount: var_amount); + var var_fees = sse_decode_opt_box_autoadd_f_64(deserializer); + return AssetInfo(name: var_name, ticker: var_ticker, amount: var_amount, fees: var_fees); } @protected @@ -3513,7 +3525,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_name = sse_decode_String(deserializer); var var_ticker = sse_decode_String(deserializer); var var_precision = sse_decode_u_8(deserializer); - return AssetMetadata(assetId: var_assetId, name: var_name, ticker: var_ticker, precision: var_precision); + var var_fiatId = sse_decode_opt_String(deserializer); + return AssetMetadata( + assetId: var_assetId, + name: var_name, + ticker: var_ticker, + precision: var_precision, + fiatId: var_fiatId, + ); } @protected @@ -3964,6 +3983,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_useDefaultExternalInputParsers = sse_decode_bool(deserializer); var var_onchainFeeRateLeewaySatPerVbyte = sse_decode_opt_box_autoadd_u_32(deserializer); var var_assetMetadata = sse_decode_opt_list_asset_metadata(deserializer); + var var_sideswapApiKey = sse_decode_opt_String(deserializer); return Config( liquidExplorer: var_liquidExplorer, bitcoinExplorer: var_bitcoinExplorer, @@ -3978,6 +3998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { useDefaultExternalInputParsers: var_useDefaultExternalInputParsers, onchainFeeRateLeewaySatPerVbyte: var_onchainFeeRateLeewaySatPerVbyte, assetMetadata: var_assetMetadata, + sideswapApiKey: var_sideswapApiKey, ); } @@ -5044,7 +5065,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { case 1: var var_assetId = sse_decode_String(deserializer); var var_receiverAmount = sse_decode_f_64(deserializer); - return PayAmount_Asset(assetId: var_assetId, receiverAmount: var_receiverAmount); + var var_estimateAssetFees = sse_decode_opt_box_autoadd_bool(deserializer); + return PayAmount_Asset( + assetId: var_assetId, + receiverAmount: var_receiverAmount, + estimateAssetFees: var_estimateAssetFees, + ); case 2: return PayAmount_Drain(); default: @@ -5383,8 +5409,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { PrepareSendResponse sse_decode_prepare_send_response(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs var var_destination = sse_decode_send_destination(deserializer); - var var_feesSat = sse_decode_u_64(deserializer); - return PrepareSendResponse(destination: var_destination, feesSat: var_feesSat); + var var_feesSat = sse_decode_opt_box_autoadd_u_64(deserializer); + var var_estimatedAssetFees = sse_decode_opt_box_autoadd_f_64(deserializer); + return PrepareSendResponse( + destination: var_destination, + feesSat: var_feesSat, + estimatedAssetFees: var_estimatedAssetFees, + ); } @protected @@ -5613,7 +5644,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SendPaymentRequest sse_decode_send_payment_request(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs var var_prepareResponse = sse_decode_prepare_send_response(deserializer); - return SendPaymentRequest(prepareResponse: var_prepareResponse); + var var_useAssetFees = sse_decode_opt_box_autoadd_bool(deserializer); + return SendPaymentRequest(prepareResponse: var_prepareResponse, useAssetFees: var_useAssetFees); } @protected @@ -5990,6 +6022,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(self.name, serializer); sse_encode_String(self.ticker, serializer); sse_encode_f_64(self.amount, serializer); + sse_encode_opt_box_autoadd_f_64(self.fees, serializer); } @protected @@ -5999,6 +6032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(self.name, serializer); sse_encode_String(self.ticker, serializer); sse_encode_u_8(self.precision, serializer); + sse_encode_opt_String(self.fiatId, serializer); } @protected @@ -6456,6 +6490,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bool(self.useDefaultExternalInputParsers, serializer); sse_encode_opt_box_autoadd_u_32(self.onchainFeeRateLeewaySatPerVbyte, serializer); sse_encode_opt_list_asset_metadata(self.assetMetadata, serializer); + sse_encode_opt_String(self.sideswapApiKey, serializer); } @protected @@ -7309,10 +7344,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { case PayAmount_Bitcoin(receiverAmountSat: final receiverAmountSat): sse_encode_i_32(0, serializer); sse_encode_u_64(receiverAmountSat, serializer); - case PayAmount_Asset(assetId: final assetId, receiverAmount: final receiverAmount): + case PayAmount_Asset( + assetId: final assetId, + receiverAmount: final receiverAmount, + estimateAssetFees: final estimateAssetFees, + ): sse_encode_i_32(1, serializer); sse_encode_String(assetId, serializer); sse_encode_f_64(receiverAmount, serializer); + sse_encode_opt_box_autoadd_bool(estimateAssetFees, serializer); case PayAmount_Drain(): sse_encode_i_32(2, serializer); } @@ -7583,7 +7623,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { void sse_encode_prepare_send_response(PrepareSendResponse self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs sse_encode_send_destination(self.destination, serializer); - sse_encode_u_64(self.feesSat, serializer); + sse_encode_opt_box_autoadd_u_64(self.feesSat, serializer); + sse_encode_opt_box_autoadd_f_64(self.estimatedAssetFees, serializer); } @protected @@ -7759,6 +7800,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { void sse_encode_send_payment_request(SendPaymentRequest self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs sse_encode_prepare_send_response(self.prepareResponse, serializer); + sse_encode_opt_box_autoadd_bool(self.useAssetFees, serializer); } @protected diff --git a/packages/dart/lib/src/frb_generated.io.dart b/packages/dart/lib/src/frb_generated.io.dart index 5778abe..0adc9f4 100644 --- a/packages/dart/lib/src/frb_generated.io.dart +++ b/packages/dart/lib/src/frb_generated.io.dart @@ -2233,6 +2233,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wireObj.name = cst_encode_String(apiObj.name); wireObj.ticker = cst_encode_String(apiObj.ticker); wireObj.amount = cst_encode_f_64(apiObj.amount); + wireObj.fees = cst_encode_opt_box_autoadd_f_64(apiObj.fees); } @protected @@ -2241,6 +2242,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wireObj.name = cst_encode_String(apiObj.name); wireObj.ticker = cst_encode_String(apiObj.ticker); wireObj.precision = cst_encode_u_8(apiObj.precision); + wireObj.fiat_id = cst_encode_opt_String(apiObj.fiatId); } @protected @@ -2724,6 +2726,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { apiObj.onchainFeeRateLeewaySatPerVbyte, ); wireObj.asset_metadata = cst_encode_opt_list_asset_metadata(apiObj.assetMetadata); + wireObj.sideswap_api_key = cst_encode_opt_String(apiObj.sideswapApiKey); } @protected @@ -3325,9 +3328,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { if (apiObj is PayAmount_Asset) { var pre_asset_id = cst_encode_String(apiObj.assetId); var pre_receiver_amount = cst_encode_f_64(apiObj.receiverAmount); + var pre_estimate_asset_fees = cst_encode_opt_box_autoadd_bool(apiObj.estimateAssetFees); wireObj.tag = 1; wireObj.kind.Asset.asset_id = pre_asset_id; wireObj.kind.Asset.receiver_amount = pre_receiver_amount; + wireObj.kind.Asset.estimate_asset_fees = pre_estimate_asset_fees; return; } if (apiObj is PayAmount_Drain) { @@ -3662,7 +3667,8 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wire_cst_prepare_send_response wireObj, ) { cst_api_fill_to_wire_send_destination(apiObj.destination, wireObj.destination); - wireObj.fees_sat = cst_encode_u_64(apiObj.feesSat); + wireObj.fees_sat = cst_encode_opt_box_autoadd_u_64(apiObj.feesSat); + wireObj.estimated_asset_fees = cst_encode_opt_box_autoadd_f_64(apiObj.estimatedAssetFees); } @protected @@ -3879,6 +3885,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wire_cst_send_payment_request wireObj, ) { cst_api_fill_to_wire_prepare_send_response(apiObj.prepareResponse, wireObj.prepare_response); + wireObj.use_asset_fees = cst_encode_opt_box_autoadd_bool(apiObj.useAssetFees); } @protected @@ -6703,6 +6710,8 @@ final class wire_cst_PayAmount_Asset extends ffi.Struct { @ffi.Double() external double receiver_amount; + + external ffi.Pointer estimate_asset_fees; } final class PayAmountKind extends ffi.Union { @@ -6822,12 +6831,15 @@ final class wire_cst_restore_request extends ffi.Struct { final class wire_cst_prepare_send_response extends ffi.Struct { external wire_cst_send_destination destination; - @ffi.Uint64() - external int fees_sat; + external ffi.Pointer fees_sat; + + external ffi.Pointer estimated_asset_fees; } final class wire_cst_send_payment_request extends ffi.Struct { external wire_cst_prepare_send_response prepare_response; + + external ffi.Pointer use_asset_fees; } final class wire_cst_sign_message_request extends ffi.Struct { @@ -6944,6 +6956,8 @@ final class wire_cst_asset_info extends ffi.Struct { @ffi.Double() external double amount; + + external ffi.Pointer fees; } final class wire_cst_PaymentDetails_Liquid extends ffi.Struct { @@ -7133,6 +7147,8 @@ final class wire_cst_asset_metadata extends ffi.Struct { @ffi.Uint8() external int precision; + + external ffi.Pointer fiat_id; } final class wire_cst_list_asset_metadata extends ffi.Struct { @@ -7171,6 +7187,8 @@ final class wire_cst_config extends ffi.Struct { external ffi.Pointer onchain_fee_rate_leeway_sat_per_vbyte; external ffi.Pointer asset_metadata; + + external ffi.Pointer sideswap_api_key; } final class wire_cst_connect_request extends ffi.Struct { @@ -7841,6 +7859,16 @@ const double LIQUID_FEE_RATE_SAT_PER_VBYTE = 0.1; const double LIQUID_FEE_RATE_MSAT_PER_VBYTE = 100.0; +const double MIN_FEE_RATE = 0.1; + +const int WEIGHT_FIXED = 222; + +const int WEIGHT_VIN_SINGLE_SIG_NATIVE = 275; + +const int WEIGHT_VIN_SINGLE_SIG_NESTED = 367; + +const int WEIGHT_VOUT_NESTED = 270; + const int DEFAULT_ZERO_CONF_MAX_SAT = 1000000; const int CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS = 4320; diff --git a/packages/dart/lib/src/model.dart b/packages/dart/lib/src/model.dart index e00745a..07c7d04 100644 --- a/packages/dart/lib/src/model.dart +++ b/packages/dart/lib/src/model.dart @@ -65,10 +65,14 @@ class AssetInfo { /// decimal shifted to the left by the [precision](AssetMetadata::precision) final double amount; - const AssetInfo({required this.name, required this.ticker, required this.amount}); + /// The optional fees when paid using the asset, having its + /// decimal shifted to the left by the [precision](AssetMetadata::precision) + final double? fees; + + const AssetInfo({required this.name, required this.ticker, required this.amount, this.fees}); @override - int get hashCode => name.hashCode ^ ticker.hashCode ^ amount.hashCode; + int get hashCode => name.hashCode ^ ticker.hashCode ^ amount.hashCode ^ fees.hashCode; @override bool operator ==(Object other) => @@ -77,7 +81,8 @@ class AssetInfo { runtimeType == other.runtimeType && name == other.name && ticker == other.ticker && - amount == other.amount; + amount == other.amount && + fees == other.fees; } /// Configuration for asset metadata. Each asset metadata item represents an entry in the @@ -97,15 +102,20 @@ class AssetMetadata { /// For example, precision of 2 shifts the decimal 2 places left from the satoshi amount. final int precision; + /// The optional ID of the fiat currency used to represent the asset + final String? fiatId; + const AssetMetadata({ required this.assetId, required this.name, required this.ticker, required this.precision, + this.fiatId, }); @override - int get hashCode => assetId.hashCode ^ name.hashCode ^ ticker.hashCode ^ precision.hashCode; + int get hashCode => + assetId.hashCode ^ name.hashCode ^ ticker.hashCode ^ precision.hashCode ^ fiatId.hashCode; @override bool operator ==(Object other) => @@ -115,7 +125,8 @@ class AssetMetadata { assetId == other.assetId && name == other.name && ticker == other.ticker && - precision == other.precision; + precision == other.precision && + fiatId == other.fiatId; } /// An argument when calling [crate::sdk::LiquidSdk::backup]. @@ -291,6 +302,9 @@ class Config { /// By default the asset metadata for Liquid Bitcoin and Tether USD are included. final List? assetMetadata; + /// The SideSwap API key used for making requests to the SideSwap payjoin service + final String? sideswapApiKey; + const Config({ required this.liquidExplorer, required this.bitcoinExplorer, @@ -305,6 +319,7 @@ class Config { required this.useDefaultExternalInputParsers, this.onchainFeeRateLeewaySatPerVbyte, this.assetMetadata, + this.sideswapApiKey, }); @override @@ -321,7 +336,8 @@ class Config { externalInputParsers.hashCode ^ useDefaultExternalInputParsers.hashCode ^ onchainFeeRateLeewaySatPerVbyte.hashCode ^ - assetMetadata.hashCode; + assetMetadata.hashCode ^ + sideswapApiKey.hashCode; @override bool operator ==(Object other) => @@ -340,7 +356,8 @@ class Config { externalInputParsers == other.externalInputParsers && useDefaultExternalInputParsers == other.useDefaultExternalInputParsers && onchainFeeRateLeewaySatPerVbyte == other.onchainFeeRateLeewaySatPerVbyte && - assetMetadata == other.assetMetadata; + assetMetadata == other.assetMetadata && + sideswapApiKey == other.sideswapApiKey; } /// An argument when calling [crate::sdk::LiquidSdk::connect]. @@ -719,7 +736,11 @@ sealed class PayAmount with _$PayAmount { const factory PayAmount.bitcoin({required BigInt receiverAmountSat}) = PayAmount_Bitcoin; /// The amount of an asset that will be received - const factory PayAmount.asset({required String assetId, required double receiverAmount}) = PayAmount_Asset; + const factory PayAmount.asset({ + required String assetId, + required double receiverAmount, + bool? estimateAssetFees, + }) = PayAmount_Asset; /// Indicates that all available Bitcoin funds should be sent const factory PayAmount.drain() = PayAmount_Drain; @@ -1365,12 +1386,20 @@ class PrepareSendRequest { /// Returned when calling [crate::sdk::LiquidSdk::prepare_send_payment]. class PrepareSendResponse { final SendDestination destination; - final BigInt feesSat; - const PrepareSendResponse({required this.destination, required this.feesSat}); + /// The optional estimated fee in satoshi. Is set when there is Bitcoin available + /// to pay fees. When not set, there are asset fees available to pay fees. + final BigInt? feesSat; + + /// The optional estimated fee in the asset. Is set when [PayAmount::Asset::estimate_asset_fees] + /// is set to `true`, the Payjoin service accepts this asset to pay fees and there + /// are funds available in this asset to pay fees. + final double? estimatedAssetFees; + + const PrepareSendResponse({required this.destination, this.feesSat, this.estimatedAssetFees}); @override - int get hashCode => destination.hashCode ^ feesSat.hashCode; + int get hashCode => destination.hashCode ^ feesSat.hashCode ^ estimatedAssetFees.hashCode; @override bool operator ==(Object other) => @@ -1378,7 +1407,8 @@ class PrepareSendResponse { other is PrepareSendResponse && runtimeType == other.runtimeType && destination == other.destination && - feesSat == other.feesSat; + feesSat == other.feesSat && + estimatedAssetFees == other.estimatedAssetFees; } @freezed @@ -1616,18 +1646,20 @@ sealed class SendDestination with _$SendDestination { /// An argument when calling [crate::sdk::LiquidSdk::send_payment]. class SendPaymentRequest { final PrepareSendResponse prepareResponse; + final bool? useAssetFees; - const SendPaymentRequest({required this.prepareResponse}); + const SendPaymentRequest({required this.prepareResponse, this.useAssetFees}); @override - int get hashCode => prepareResponse.hashCode; + int get hashCode => prepareResponse.hashCode ^ useAssetFees.hashCode; @override bool operator ==(Object other) => identical(this, other) || other is SendPaymentRequest && runtimeType == other.runtimeType && - prepareResponse == other.prepareResponse; + prepareResponse == other.prepareResponse && + useAssetFees == other.useAssetFees; } /// Returned when calling [crate::sdk::LiquidSdk::send_payment]. diff --git a/packages/dart/lib/src/model.freezed.dart b/packages/dart/lib/src/model.freezed.dart index 76fcc53..783b128 100644 --- a/packages/dart/lib/src/model.freezed.dart +++ b/packages/dart/lib/src/model.freezed.dart @@ -865,11 +865,12 @@ as BigInt, class PayAmount_Asset extends PayAmount { - const PayAmount_Asset({required this.assetId, required this.receiverAmount}): super._(); + const PayAmount_Asset({required this.assetId, required this.receiverAmount, this.estimateAssetFees}): super._(); final String assetId; final double receiverAmount; + final bool? estimateAssetFees; /// Create a copy of PayAmount /// with the given fields replaced by the non-null parameter values. @@ -881,16 +882,16 @@ $PayAmount_AssetCopyWith get copyWith => _$PayAmount_AssetCopyW @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is PayAmount_Asset&&(identical(other.assetId, assetId) || other.assetId == assetId)&&(identical(other.receiverAmount, receiverAmount) || other.receiverAmount == receiverAmount)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is PayAmount_Asset&&(identical(other.assetId, assetId) || other.assetId == assetId)&&(identical(other.receiverAmount, receiverAmount) || other.receiverAmount == receiverAmount)&&(identical(other.estimateAssetFees, estimateAssetFees) || other.estimateAssetFees == estimateAssetFees)); } @override -int get hashCode => Object.hash(runtimeType,assetId,receiverAmount); +int get hashCode => Object.hash(runtimeType,assetId,receiverAmount,estimateAssetFees); @override String toString() { - return 'PayAmount.asset(assetId: $assetId, receiverAmount: $receiverAmount)'; + return 'PayAmount.asset(assetId: $assetId, receiverAmount: $receiverAmount, estimateAssetFees: $estimateAssetFees)'; } @@ -901,7 +902,7 @@ abstract mixin class $PayAmount_AssetCopyWith<$Res> implements $PayAmountCopyWit factory $PayAmount_AssetCopyWith(PayAmount_Asset value, $Res Function(PayAmount_Asset) _then) = _$PayAmount_AssetCopyWithImpl; @useResult $Res call({ - String assetId, double receiverAmount + String assetId, double receiverAmount, bool? estimateAssetFees }); @@ -918,11 +919,12 @@ class _$PayAmount_AssetCopyWithImpl<$Res> /// Create a copy of PayAmount /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? assetId = null,Object? receiverAmount = null,}) { +@pragma('vm:prefer-inline') $Res call({Object? assetId = null,Object? receiverAmount = null,Object? estimateAssetFees = freezed,}) { return _then(PayAmount_Asset( assetId: null == assetId ? _self.assetId : assetId // ignore: cast_nullable_to_non_nullable as String,receiverAmount: null == receiverAmount ? _self.receiverAmount : receiverAmount // ignore: cast_nullable_to_non_nullable -as double, +as double,estimateAssetFees: freezed == estimateAssetFees ? _self.estimateAssetFees : estimateAssetFees // ignore: cast_nullable_to_non_nullable +as bool?, )); } diff --git a/packages/flutter/lib/flutter_breez_liquid_bindings_generated.dart b/packages/flutter/lib/flutter_breez_liquid_bindings_generated.dart index 2f6b62f..a4c80a8 100644 --- a/packages/flutter/lib/flutter_breez_liquid_bindings_generated.dart +++ b/packages/flutter/lib/flutter_breez_liquid_bindings_generated.dart @@ -4603,6 +4603,8 @@ final class wire_cst_PayAmount_Asset extends ffi.Struct { @ffi.Double() external double receiver_amount; + + external ffi.Pointer estimate_asset_fees; } final class PayAmountKind extends ffi.Union { @@ -4722,12 +4724,15 @@ final class wire_cst_restore_request extends ffi.Struct { final class wire_cst_prepare_send_response extends ffi.Struct { external wire_cst_send_destination destination; - @ffi.Uint64() - external int fees_sat; + external ffi.Pointer fees_sat; + + external ffi.Pointer estimated_asset_fees; } final class wire_cst_send_payment_request extends ffi.Struct { external wire_cst_prepare_send_response prepare_response; + + external ffi.Pointer use_asset_fees; } final class wire_cst_sign_message_request extends ffi.Struct { @@ -4844,6 +4849,8 @@ final class wire_cst_asset_info extends ffi.Struct { @ffi.Double() external double amount; + + external ffi.Pointer fees; } final class wire_cst_PaymentDetails_Liquid extends ffi.Struct { @@ -5033,6 +5040,8 @@ final class wire_cst_asset_metadata extends ffi.Struct { @ffi.Uint8() external int precision; + + external ffi.Pointer fiat_id; } final class wire_cst_list_asset_metadata extends ffi.Struct { @@ -5071,6 +5080,8 @@ final class wire_cst_config extends ffi.Struct { external ffi.Pointer onchain_fee_rate_leeway_sat_per_vbyte; external ffi.Pointer asset_metadata; + + external ffi.Pointer sideswap_api_key; } final class wire_cst_connect_request extends ffi.Struct { @@ -6033,6 +6044,16 @@ const double LIQUID_FEE_RATE_SAT_PER_VBYTE = 0.1; const double LIQUID_FEE_RATE_MSAT_PER_VBYTE = 100.0; +const double MIN_FEE_RATE = 0.1; + +const int WEIGHT_FIXED = 222; + +const int WEIGHT_VIN_SINGLE_SIG_NATIVE = 275; + +const int WEIGHT_VIN_SINGLE_SIG_NESTED = 367; + +const int WEIGHT_VOUT_NESTED = 270; + const int DEFAULT_ZERO_CONF_MAX_SAT = 1000000; const int CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS = 4320; diff --git a/packages/react-native/android/src/main/java/com/breezsdkliquid/BreezSDKLiquidMapper.kt b/packages/react-native/android/src/main/java/com/breezsdkliquid/BreezSDKLiquidMapper.kt index 09eea83..90754cd 100644 --- a/packages/react-native/android/src/main/java/com/breezsdkliquid/BreezSDKLiquidMapper.kt +++ b/packages/react-native/android/src/main/java/com/breezsdkliquid/BreezSDKLiquidMapper.kt @@ -156,7 +156,8 @@ fun asAssetInfo(assetInfo: ReadableMap): AssetInfo? { val name = assetInfo.getString("name")!! val ticker = assetInfo.getString("ticker")!! val amount = assetInfo.getDouble("amount") - return AssetInfo(name, ticker, amount) + val fees = if (hasNonNullKey(assetInfo, "fees")) assetInfo.getDouble("fees") else null + return AssetInfo(name, ticker, amount, fees) } fun readableMapOf(assetInfo: AssetInfo): ReadableMap = @@ -164,6 +165,7 @@ fun readableMapOf(assetInfo: AssetInfo): ReadableMap = "name" to assetInfo.name, "ticker" to assetInfo.ticker, "amount" to assetInfo.amount, + "fees" to assetInfo.fees, ) fun asAssetInfoList(arr: ReadableArray): List { @@ -194,7 +196,8 @@ fun asAssetMetadata(assetMetadata: ReadableMap): AssetMetadata? { val name = assetMetadata.getString("name")!! val ticker = assetMetadata.getString("ticker")!! val precision = assetMetadata.getInt("precision").toUByte() - return AssetMetadata(assetId, name, ticker, precision) + val fiatId = if (hasNonNullKey(assetMetadata, "fiatId")) assetMetadata.getString("fiatId") else null + return AssetMetadata(assetId, name, ticker, precision, fiatId) } fun readableMapOf(assetMetadata: AssetMetadata): ReadableMap = @@ -203,6 +206,7 @@ fun readableMapOf(assetMetadata: AssetMetadata): ReadableMap = "name" to assetMetadata.name, "ticker" to assetMetadata.ticker, "precision" to assetMetadata.precision, + "fiatId" to assetMetadata.fiatId, ) fun asAssetMetadataList(arr: ReadableArray): List { @@ -476,6 +480,7 @@ fun asConfig(config: ReadableMap): Config? { } else { null } + val sideswapApiKey = if (hasNonNullKey(config, "sideswapApiKey")) config.getString("sideswapApiKey") else null return Config( liquidExplorer, bitcoinExplorer, @@ -490,6 +495,7 @@ fun asConfig(config: ReadableMap): Config? { externalInputParsers, onchainFeeRateLeewaySatPerVbyte, assetMetadata, + sideswapApiKey, ) } @@ -508,6 +514,7 @@ fun readableMapOf(config: Config): ReadableMap = "externalInputParsers" to config.externalInputParsers?.let { readableArrayOf(it) }, "onchainFeeRateLeewaySatPerVbyte" to config.onchainFeeRateLeewaySatPerVbyte, "assetMetadata" to config.assetMetadata?.let { readableArrayOf(it) }, + "sideswapApiKey" to config.sideswapApiKey, ) fun asConfigList(arr: ReadableArray): List { @@ -2281,21 +2288,31 @@ fun asPrepareSendResponse(prepareSendResponse: ReadableMap): PrepareSendResponse prepareSendResponse, arrayOf( "destination", - "feesSat", ), ) ) { return null } val destination = prepareSendResponse.getMap("destination")?.let { asSendDestination(it) }!! - val feesSat = prepareSendResponse.getDouble("feesSat").toULong() - return PrepareSendResponse(destination, feesSat) + val feesSat = if (hasNonNullKey(prepareSendResponse, "feesSat")) prepareSendResponse.getDouble("feesSat").toULong() else null + val estimatedAssetFees = + if (hasNonNullKey( + prepareSendResponse, + "estimatedAssetFees", + ) + ) { + prepareSendResponse.getDouble("estimatedAssetFees") + } else { + null + } + return PrepareSendResponse(destination, feesSat, estimatedAssetFees) } fun readableMapOf(prepareSendResponse: PrepareSendResponse): ReadableMap = readableMapOf( "destination" to readableMapOf(prepareSendResponse.destination), "feesSat" to prepareSendResponse.feesSat, + "estimatedAssetFees" to prepareSendResponse.estimatedAssetFees, ) fun asPrepareSendResponseList(arr: ReadableArray): List { @@ -2684,12 +2701,14 @@ fun asSendPaymentRequest(sendPaymentRequest: ReadableMap): SendPaymentRequest? { return null } val prepareResponse = sendPaymentRequest.getMap("prepareResponse")?.let { asPrepareSendResponse(it) }!! - return SendPaymentRequest(prepareResponse) + val useAssetFees = if (hasNonNullKey(sendPaymentRequest, "useAssetFees")) sendPaymentRequest.getBoolean("useAssetFees") else null + return SendPaymentRequest(prepareResponse, useAssetFees) } fun readableMapOf(sendPaymentRequest: SendPaymentRequest): ReadableMap = readableMapOf( "prepareResponse" to readableMapOf(sendPaymentRequest.prepareResponse), + "useAssetFees" to sendPaymentRequest.useAssetFees, ) fun asSendPaymentRequestList(arr: ReadableArray): List { @@ -3405,7 +3424,8 @@ fun asPayAmount(payAmount: ReadableMap): PayAmount? { if (type == "asset") { val assetId = payAmount.getString("assetId")!! val receiverAmount = payAmount.getDouble("receiverAmount") - return PayAmount.Asset(assetId, receiverAmount) + val estimateAssetFees = if (hasNonNullKey(payAmount, "estimateAssetFees")) payAmount.getBoolean("estimateAssetFees") else null + return PayAmount.Asset(assetId, receiverAmount, estimateAssetFees) } if (type == "drain") { return PayAmount.Drain @@ -3424,6 +3444,7 @@ fun readableMapOf(payAmount: PayAmount): ReadableMap? { pushToMap(map, "type", "asset") pushToMap(map, "assetId", payAmount.assetId) pushToMap(map, "receiverAmount", payAmount.receiverAmount) + pushToMap(map, "estimateAssetFees", payAmount.estimateAssetFees) } is PayAmount.Drain -> { pushToMap(map, "type", "drain") diff --git a/packages/react-native/ios/BreezSDKLiquidMapper.swift b/packages/react-native/ios/BreezSDKLiquidMapper.swift index 7d1427a..855cba5 100644 --- a/packages/react-native/ios/BreezSDKLiquidMapper.swift +++ b/packages/react-native/ios/BreezSDKLiquidMapper.swift @@ -177,8 +177,15 @@ enum BreezSDKLiquidMapper { guard let amount = assetInfo["amount"] as? Double else { throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "amount", typeName: "AssetInfo")) } + var fees: Double? + if hasNonNilKey(data: assetInfo, key: "fees") { + guard let feesTmp = assetInfo["fees"] as? Double else { + throw SdkError.Generic(message: errUnexpectedValue(fieldName: "fees")) + } + fees = feesTmp + } - return AssetInfo(name: name, ticker: ticker, amount: amount) + return AssetInfo(name: name, ticker: ticker, amount: amount, fees: fees) } static func dictionaryOf(assetInfo: AssetInfo) -> [String: Any?] { @@ -186,6 +193,7 @@ enum BreezSDKLiquidMapper { "name": assetInfo.name, "ticker": assetInfo.ticker, "amount": assetInfo.amount, + "fees": assetInfo.fees == nil ? nil : assetInfo.fees, ] } @@ -219,8 +227,15 @@ enum BreezSDKLiquidMapper { guard let precision = assetMetadata["precision"] as? UInt8 else { throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "precision", typeName: "AssetMetadata")) } + var fiatId: String? + if hasNonNilKey(data: assetMetadata, key: "fiatId") { + guard let fiatIdTmp = assetMetadata["fiatId"] as? String else { + throw SdkError.Generic(message: errUnexpectedValue(fieldName: "fiatId")) + } + fiatId = fiatIdTmp + } - return AssetMetadata(assetId: assetId, name: name, ticker: ticker, precision: precision) + return AssetMetadata(assetId: assetId, name: name, ticker: ticker, precision: precision, fiatId: fiatId) } static func dictionaryOf(assetMetadata: AssetMetadata) -> [String: Any?] { @@ -229,6 +244,7 @@ enum BreezSDKLiquidMapper { "name": assetMetadata.name, "ticker": assetMetadata.ticker, "precision": assetMetadata.precision, + "fiatId": assetMetadata.fiatId == nil ? nil : assetMetadata.fiatId, ] } @@ -561,7 +577,15 @@ enum BreezSDKLiquidMapper { assetMetadata = try asAssetMetadataList(arr: assetMetadataTmp) } - return Config(liquidExplorer: liquidExplorer, bitcoinExplorer: bitcoinExplorer, workingDir: workingDir, network: network, paymentTimeoutSec: paymentTimeoutSec, syncServiceUrl: syncServiceUrl, breezApiKey: breezApiKey, cacheDir: cacheDir, zeroConfMaxAmountSat: zeroConfMaxAmountSat, useDefaultExternalInputParsers: useDefaultExternalInputParsers, externalInputParsers: externalInputParsers, onchainFeeRateLeewaySatPerVbyte: onchainFeeRateLeewaySatPerVbyte, assetMetadata: assetMetadata) + var sideswapApiKey: String? + if hasNonNilKey(data: config, key: "sideswapApiKey") { + guard let sideswapApiKeyTmp = config["sideswapApiKey"] as? String else { + throw SdkError.Generic(message: errUnexpectedValue(fieldName: "sideswapApiKey")) + } + sideswapApiKey = sideswapApiKeyTmp + } + + return Config(liquidExplorer: liquidExplorer, bitcoinExplorer: bitcoinExplorer, workingDir: workingDir, network: network, paymentTimeoutSec: paymentTimeoutSec, syncServiceUrl: syncServiceUrl, breezApiKey: breezApiKey, cacheDir: cacheDir, zeroConfMaxAmountSat: zeroConfMaxAmountSat, useDefaultExternalInputParsers: useDefaultExternalInputParsers, externalInputParsers: externalInputParsers, onchainFeeRateLeewaySatPerVbyte: onchainFeeRateLeewaySatPerVbyte, assetMetadata: assetMetadata, sideswapApiKey: sideswapApiKey) } static func dictionaryOf(config: Config) -> [String: Any?] { @@ -579,6 +603,7 @@ enum BreezSDKLiquidMapper { "externalInputParsers": config.externalInputParsers == nil ? nil : arrayOf(externalInputParserList: config.externalInputParsers!), "onchainFeeRateLeewaySatPerVbyte": config.onchainFeeRateLeewaySatPerVbyte == nil ? nil : config.onchainFeeRateLeewaySatPerVbyte, "assetMetadata": config.assetMetadata == nil ? nil : arrayOf(assetMetadataList: config.assetMetadata!), + "sideswapApiKey": config.sideswapApiKey == nil ? nil : config.sideswapApiKey, ] } @@ -2652,17 +2677,29 @@ enum BreezSDKLiquidMapper { } let destination = try asSendDestination(sendDestination: destinationTmp) - guard let feesSat = prepareSendResponse["feesSat"] as? UInt64 else { - throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "feesSat", typeName: "PrepareSendResponse")) + var feesSat: UInt64? + if hasNonNilKey(data: prepareSendResponse, key: "feesSat") { + guard let feesSatTmp = prepareSendResponse["feesSat"] as? UInt64 else { + throw SdkError.Generic(message: errUnexpectedValue(fieldName: "feesSat")) + } + feesSat = feesSatTmp + } + var estimatedAssetFees: Double? + if hasNonNilKey(data: prepareSendResponse, key: "estimatedAssetFees") { + guard let estimatedAssetFeesTmp = prepareSendResponse["estimatedAssetFees"] as? Double else { + throw SdkError.Generic(message: errUnexpectedValue(fieldName: "estimatedAssetFees")) + } + estimatedAssetFees = estimatedAssetFeesTmp } - return PrepareSendResponse(destination: destination, feesSat: feesSat) + return PrepareSendResponse(destination: destination, feesSat: feesSat, estimatedAssetFees: estimatedAssetFees) } static func dictionaryOf(prepareSendResponse: PrepareSendResponse) -> [String: Any?] { return [ "destination": dictionaryOf(sendDestination: prepareSendResponse.destination), - "feesSat": prepareSendResponse.feesSat, + "feesSat": prepareSendResponse.feesSat == nil ? nil : prepareSendResponse.feesSat, + "estimatedAssetFees": prepareSendResponse.estimatedAssetFees == nil ? nil : prepareSendResponse.estimatedAssetFees, ] } @@ -3098,12 +3135,21 @@ enum BreezSDKLiquidMapper { } let prepareResponse = try asPrepareSendResponse(prepareSendResponse: prepareResponseTmp) - return SendPaymentRequest(prepareResponse: prepareResponse) + var useAssetFees: Bool? + if hasNonNilKey(data: sendPaymentRequest, key: "useAssetFees") { + guard let useAssetFeesTmp = sendPaymentRequest["useAssetFees"] as? Bool else { + throw SdkError.Generic(message: errUnexpectedValue(fieldName: "useAssetFees")) + } + useAssetFees = useAssetFeesTmp + } + + return SendPaymentRequest(prepareResponse: prepareResponse, useAssetFees: useAssetFees) } static func dictionaryOf(sendPaymentRequest: SendPaymentRequest) -> [String: Any?] { return [ "prepareResponse": dictionaryOf(prepareSendResponse: sendPaymentRequest.prepareResponse), + "useAssetFees": sendPaymentRequest.useAssetFees == nil ? nil : sendPaymentRequest.useAssetFees, ] } @@ -4188,7 +4234,9 @@ enum BreezSDKLiquidMapper { guard let _receiverAmount = payAmount["receiverAmount"] as? Double else { throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "receiverAmount", typeName: "PayAmount")) } - return PayAmount.asset(assetId: _assetId, receiverAmount: _receiverAmount) + let _estimateAssetFees = payAmount["estimateAssetFees"] as? Bool + + return PayAmount.asset(assetId: _assetId, receiverAmount: _receiverAmount, estimateAssetFees: _estimateAssetFees) } if type == "drain" { return PayAmount.drain @@ -4208,12 +4256,13 @@ enum BreezSDKLiquidMapper { ] case let .asset( - assetId, receiverAmount + assetId, receiverAmount, estimateAssetFees ): return [ "type": "asset", "assetId": assetId, "receiverAmount": receiverAmount, + "estimateAssetFees": estimateAssetFees == nil ? nil : estimateAssetFees, ] case .drain: diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index b1dbc56..b7fb5f7 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -46,6 +46,7 @@ export interface AssetInfo { name: string ticker: string amount: number + fees?: number } export interface AssetMetadata { @@ -53,6 +54,7 @@ export interface AssetMetadata { name: string ticker: string precision: number + fiatId?: string } export interface BackupRequest { @@ -101,6 +103,7 @@ export interface Config { externalInputParsers?: ExternalInputParser[] onchainFeeRateLeewaySatPerVbyte?: number assetMetadata?: AssetMetadata[] + sideswapApiKey?: string } export interface ConnectRequest { @@ -392,7 +395,8 @@ export interface PrepareSendRequest { export interface PrepareSendResponse { destination: SendDestination - feesSat: number + feesSat?: number + estimatedAssetFees?: number } export interface Rate { @@ -455,6 +459,7 @@ export interface RouteHintHop { export interface SendPaymentRequest { prepareResponse: PrepareSendResponse + useAssetFees?: boolean } export interface SendPaymentResponse { @@ -682,6 +687,7 @@ export type PayAmount = { type: PayAmountVariant.ASSET, assetId: string receiverAmount: number + estimateAssetFees?: boolean } | { type: PayAmountVariant.DRAIN }