Pay fees with USDT asset (payjoin) (#779)

* Add payjoin implementation

* Fix Core Wasm tests
This commit is contained in:
Ross Savage
2025-04-02 13:05:02 +02:00
committed by GitHub
parent f813939529
commit 0c88e09fc3
37 changed files with 3309 additions and 123 deletions

View File

@@ -43,6 +43,10 @@ pub(crate) enum Command {
#[clap(long = "asset")] #[clap(long = "asset")]
asset_id: Option<String>, asset_id: Option<String>,
/// Whether or not the tx should be paid using the asset
#[clap(long, action = ArgAction::SetTrue)]
use_asset_fees: Option<bool>,
/// The amount to pay, in case of a Liquid payment. The amount is optional if it is already /// The amount to pay, in case of a Liquid payment. The amount is optional if it is already
/// provided in the BIP21 URI. /// provided in the BIP21 URI.
/// The asset id must also be provided. /// The asset id must also be provided.
@@ -384,6 +388,7 @@ pub(crate) async fn handle_command(
amount, amount,
amount_sat, amount_sat,
asset_id, asset_id,
use_asset_fees,
drain, drain,
delay, delay,
} => { } => {
@@ -409,6 +414,7 @@ pub(crate) async fn handle_command(
(Some(asset_id), Some(receiver_amount), _, _) => Some(PayAmount::Asset { (Some(asset_id), Some(receiver_amount), _, _) => Some(PayAmount::Asset {
asset_id, asset_id,
receiver_amount, receiver_amount,
estimate_asset_fees: use_asset_fees,
}), }),
(None, None, Some(receiver_amount_sat), _) => Some(PayAmount::Bitcoin { (None, None, Some(receiver_amount_sat), _) => Some(PayAmount::Bitcoin {
receiver_amount_sat, receiver_amount_sat,
@@ -424,16 +430,30 @@ pub(crate) async fn handle_command(
}) })
.await?; .await?;
wait_confirmation!( let confirmation_msg = match (
format!( use_asset_fees.unwrap_or(false),
"Fees: {} sat. Are the fees acceptable? (y/N) ", prepare_response.fees_sat,
prepare_response.fees_sat prepare_response.estimated_asset_fees,
), ) {
"Payment send halted" (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 { let send_payment_req = SendPaymentRequest {
prepare_response: prepare_response.clone(), prepare_response: prepare_response.clone(),
use_asset_fees,
}; };
if let Some(delay) = delay { if let Some(delay) = delay {

View File

@@ -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 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 * 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 { typedef struct wire_cst_PayAmount_Asset {
struct wire_cst_list_prim_u_8_strict *asset_id; struct wire_cst_list_prim_u_8_strict *asset_id;
double receiver_amount; double receiver_amount;
bool *estimate_asset_fees;
} wire_cst_PayAmount_Asset; } wire_cst_PayAmount_Asset;
typedef union PayAmountKind { typedef union PayAmountKind {
@@ -445,11 +456,13 @@ typedef struct wire_cst_restore_request {
typedef struct wire_cst_prepare_send_response { typedef struct wire_cst_prepare_send_response {
struct wire_cst_send_destination destination; struct wire_cst_send_destination destination;
uint64_t fees_sat; uint64_t *fees_sat;
double *estimated_asset_fees;
} wire_cst_prepare_send_response; } wire_cst_prepare_send_response;
typedef struct wire_cst_send_payment_request { typedef struct wire_cst_send_payment_request {
struct wire_cst_prepare_send_response prepare_response; struct wire_cst_prepare_send_response prepare_response;
bool *use_asset_fees;
} wire_cst_send_payment_request; } wire_cst_send_payment_request;
typedef struct wire_cst_sign_message_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 *name;
struct wire_cst_list_prim_u_8_strict *ticker; struct wire_cst_list_prim_u_8_strict *ticker;
double amount; double amount;
double *fees;
} wire_cst_asset_info; } wire_cst_asset_info;
typedef struct wire_cst_PaymentDetails_Liquid { 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 *name;
struct wire_cst_list_prim_u_8_strict *ticker; struct wire_cst_list_prim_u_8_strict *ticker;
uint8_t precision; uint8_t precision;
struct wire_cst_list_prim_u_8_strict *fiat_id;
} wire_cst_asset_metadata; } wire_cst_asset_metadata;
typedef struct wire_cst_list_asset_metadata { typedef struct wire_cst_list_asset_metadata {
@@ -691,6 +706,7 @@ typedef struct wire_cst_config {
bool use_default_external_input_parsers; bool use_default_external_input_parsers;
uint32_t *onchain_fee_rate_leeway_sat_per_vbyte; uint32_t *onchain_fee_rate_leeway_sat_per_vbyte;
struct wire_cst_list_asset_metadata *asset_metadata; struct wire_cst_list_asset_metadata *asset_metadata;
struct wire_cst_list_prim_u_8_strict *sideswap_api_key;
} wire_cst_config; } wire_cst_config;
typedef struct wire_cst_connect_request { typedef struct wire_cst_connect_request {

View File

@@ -345,6 +345,7 @@ dictionary Config {
sequence<ExternalInputParser>? external_input_parsers = null; sequence<ExternalInputParser>? external_input_parsers = null;
u32? onchain_fee_rate_leeway_sat_per_vbyte = null; u32? onchain_fee_rate_leeway_sat_per_vbyte = null;
sequence<AssetMetadata>? asset_metadata = null; sequence<AssetMetadata>? asset_metadata = null;
string? sideswap_api_key = null;
}; };
enum LiquidNetwork { enum LiquidNetwork {
@@ -443,11 +444,13 @@ interface SendDestination {
dictionary PrepareSendResponse { dictionary PrepareSendResponse {
SendDestination destination; SendDestination destination;
u64 fees_sat; u64? fees_sat;
f64? estimated_asset_fees;
}; };
dictionary SendPaymentRequest { dictionary SendPaymentRequest {
PrepareSendResponse prepare_response; PrepareSendResponse prepare_response;
boolean? use_asset_fees = null;
}; };
dictionary SendPaymentResponse { dictionary SendPaymentResponse {
@@ -509,7 +512,7 @@ dictionary OnchainPaymentLimitsResponse {
[Enum] [Enum]
interface PayAmount { interface PayAmount {
Bitcoin(u64 receiver_amount_sat); Bitcoin(u64 receiver_amount_sat);
Asset(string asset_id, f64 receiver_amount); Asset(string asset_id, f64 receiver_amount, boolean? estimate_asset_fees);
Drain(); Drain();
}; };
@@ -609,6 +612,7 @@ dictionary AssetInfo {
string name; string name;
string ticker; string ticker;
f64 amount; f64 amount;
f64? fees;
}; };
[Enum] [Enum]
@@ -722,6 +726,7 @@ dictionary AssetMetadata {
string name; string name;
string ticker; string ticker;
u8 precision; u8 precision;
string? fiat_id = null;
}; };
namespace breez_sdk_liquid { namespace breez_sdk_liquid {

View File

@@ -2,6 +2,8 @@ use anyhow::Error;
use lwk_wollet::secp256k1; use lwk_wollet::secp256k1;
use sdk_common::prelude::{LnUrlAuthError, LnUrlPayError, LnUrlWithdrawError}; use sdk_common::prelude::{LnUrlAuthError, LnUrlPayError, LnUrlWithdrawError};
use crate::payjoin::error::PayjoinError;
pub type SdkResult<T, E = SdkError> = Result<T, E>; pub type SdkResult<T, E = SdkError> = Result<T, E>;
#[macro_export] #[macro_export]
@@ -219,6 +221,17 @@ impl From<anyhow::Error> for PaymentError {
} }
} }
impl From<PayjoinError> for PaymentError {
fn from(err: PayjoinError) -> Self {
match err {
PayjoinError::InsufficientFunds => PaymentError::InsufficientFunds,
_ => PaymentError::Generic {
err: format!("{err:?}"),
},
}
}
}
impl From<rusqlite::Error> for PaymentError { impl From<rusqlite::Error> for PaymentError {
fn from(_: rusqlite::Error) -> Self { fn from(_: rusqlite::Error) -> Self {
Self::PersistError Self::PersistError

View File

@@ -2413,10 +2413,12 @@ impl SseDecode for crate::model::AssetInfo {
let mut var_name = <String>::sse_decode(deserializer); let mut var_name = <String>::sse_decode(deserializer);
let mut var_ticker = <String>::sse_decode(deserializer); let mut var_ticker = <String>::sse_decode(deserializer);
let mut var_amount = <f64>::sse_decode(deserializer); let mut var_amount = <f64>::sse_decode(deserializer);
let mut var_fees = <Option<f64>>::sse_decode(deserializer);
return crate::model::AssetInfo { return crate::model::AssetInfo {
name: var_name, name: var_name,
ticker: var_ticker, ticker: var_ticker,
amount: var_amount, amount: var_amount,
fees: var_fees,
}; };
} }
} }
@@ -2428,11 +2430,13 @@ impl SseDecode for crate::model::AssetMetadata {
let mut var_name = <String>::sse_decode(deserializer); let mut var_name = <String>::sse_decode(deserializer);
let mut var_ticker = <String>::sse_decode(deserializer); let mut var_ticker = <String>::sse_decode(deserializer);
let mut var_precision = <u8>::sse_decode(deserializer); let mut var_precision = <u8>::sse_decode(deserializer);
let mut var_fiatId = <Option<String>>::sse_decode(deserializer);
return crate::model::AssetMetadata { return crate::model::AssetMetadata {
asset_id: var_assetId, asset_id: var_assetId,
name: var_name, name: var_name,
ticker: var_ticker, ticker: var_ticker,
precision: var_precision, precision: var_precision,
fiat_id: var_fiatId,
}; };
} }
} }
@@ -2585,6 +2589,7 @@ impl SseDecode for crate::model::Config {
let mut var_onchainFeeRateLeewaySatPerVbyte = <Option<u32>>::sse_decode(deserializer); let mut var_onchainFeeRateLeewaySatPerVbyte = <Option<u32>>::sse_decode(deserializer);
let mut var_assetMetadata = let mut var_assetMetadata =
<Option<Vec<crate::model::AssetMetadata>>>::sse_decode(deserializer); <Option<Vec<crate::model::AssetMetadata>>>::sse_decode(deserializer);
let mut var_sideswapApiKey = <Option<String>>::sse_decode(deserializer);
return crate::model::Config { return crate::model::Config {
liquid_explorer: var_liquidExplorer, liquid_explorer: var_liquidExplorer,
bitcoin_explorer: var_bitcoinExplorer, bitcoin_explorer: var_bitcoinExplorer,
@@ -2599,6 +2604,7 @@ impl SseDecode for crate::model::Config {
use_default_external_input_parsers: var_useDefaultExternalInputParsers, use_default_external_input_parsers: var_useDefaultExternalInputParsers,
onchain_fee_rate_leeway_sat_per_vbyte: var_onchainFeeRateLeewaySatPerVbyte, onchain_fee_rate_leeway_sat_per_vbyte: var_onchainFeeRateLeewaySatPerVbyte,
asset_metadata: var_assetMetadata, asset_metadata: var_assetMetadata,
sideswap_api_key: var_sideswapApiKey,
}; };
} }
} }
@@ -3885,9 +3891,11 @@ impl SseDecode for crate::model::PayAmount {
1 => { 1 => {
let mut var_assetId = <String>::sse_decode(deserializer); let mut var_assetId = <String>::sse_decode(deserializer);
let mut var_receiverAmount = <f64>::sse_decode(deserializer); let mut var_receiverAmount = <f64>::sse_decode(deserializer);
let mut var_estimateAssetFees = <Option<bool>>::sse_decode(deserializer);
return crate::model::PayAmount::Asset { return crate::model::PayAmount::Asset {
asset_id: var_assetId, asset_id: var_assetId,
receiver_amount: var_receiverAmount, receiver_amount: var_receiverAmount,
estimate_asset_fees: var_estimateAssetFees,
}; };
} }
2 => { 2 => {
@@ -4316,10 +4324,12 @@ impl SseDecode for crate::model::PrepareSendResponse {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_destination = <crate::model::SendDestination>::sse_decode(deserializer); let mut var_destination = <crate::model::SendDestination>::sse_decode(deserializer);
let mut var_feesSat = <u64>::sse_decode(deserializer); let mut var_feesSat = <Option<u64>>::sse_decode(deserializer);
let mut var_estimatedAssetFees = <Option<f64>>::sse_decode(deserializer);
return crate::model::PrepareSendResponse { return crate::model::PrepareSendResponse {
destination: var_destination, destination: var_destination,
fees_sat: var_feesSat, 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 // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_prepareResponse = <crate::model::PrepareSendResponse>::sse_decode(deserializer); let mut var_prepareResponse = <crate::model::PrepareSendResponse>::sse_decode(deserializer);
let mut var_useAssetFees = <Option<bool>>::sse_decode(deserializer);
return crate::model::SendPaymentRequest { return crate::model::SendPaymentRequest {
prepare_response: var_prepareResponse, 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.name.into_into_dart().into_dart(),
self.ticker.into_into_dart().into_dart(), self.ticker.into_into_dart().into_dart(),
self.amount.into_into_dart().into_dart(), self.amount.into_into_dart().into_dart(),
self.fees.into_into_dart().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.name.into_into_dart().into_dart(),
self.ticker.into_into_dart().into_dart(), self.ticker.into_into_dart().into_dart(),
self.precision.into_into_dart().into_dart(), self.precision.into_into_dart().into_dart(),
self.fiat_id.into_into_dart().into_dart(),
] ]
.into_dart() .into_dart()
} }
@@ -5228,6 +5242,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::Config {
.into_into_dart() .into_into_dart()
.into_dart(), .into_dart(),
self.asset_metadata.into_into_dart().into_dart(), self.asset_metadata.into_into_dart().into_dart(),
self.sideswap_api_key.into_into_dart().into_dart(),
] ]
.into_dart() .into_dart()
} }
@@ -6246,10 +6261,12 @@ impl flutter_rust_bridge::IntoDart for crate::model::PayAmount {
crate::model::PayAmount::Asset { crate::model::PayAmount::Asset {
asset_id, asset_id,
receiver_amount, receiver_amount,
estimate_asset_fees,
} => [ } => [
1.into_dart(), 1.into_dart(),
asset_id.into_into_dart().into_dart(), asset_id.into_into_dart().into_dart(),
receiver_amount.into_into_dart().into_dart(), receiver_amount.into_into_dart().into_dart(),
estimate_asset_fees.into_into_dart().into_dart(),
] ]
.into_dart(), .into_dart(),
crate::model::PayAmount::Drain => [2.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.destination.into_into_dart().into_dart(),
self.fees_sat.into_into_dart().into_dart(), self.fees_sat.into_into_dart().into_dart(),
self.estimated_asset_fees.into_into_dart().into_dart(),
] ]
.into_dart() .into_dart()
} }
@@ -7128,7 +7146,11 @@ impl flutter_rust_bridge::IntoIntoDart<crate::model::SendDestination>
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::model::SendPaymentRequest { impl flutter_rust_bridge::IntoDart for crate::model::SendPaymentRequest {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { 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 impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
@@ -7452,6 +7474,7 @@ impl SseEncode for crate::model::AssetInfo {
<String>::sse_encode(self.name, serializer); <String>::sse_encode(self.name, serializer);
<String>::sse_encode(self.ticker, serializer); <String>::sse_encode(self.ticker, serializer);
<f64>::sse_encode(self.amount, serializer); <f64>::sse_encode(self.amount, serializer);
<Option<f64>>::sse_encode(self.fees, serializer);
} }
} }
@@ -7462,6 +7485,7 @@ impl SseEncode for crate::model::AssetMetadata {
<String>::sse_encode(self.name, serializer); <String>::sse_encode(self.name, serializer);
<String>::sse_encode(self.ticker, serializer); <String>::sse_encode(self.ticker, serializer);
<u8>::sse_encode(self.precision, serializer); <u8>::sse_encode(self.precision, serializer);
<Option<String>>::sse_encode(self.fiat_id, serializer);
} }
} }
@@ -7586,6 +7610,7 @@ impl SseEncode for crate::model::Config {
<bool>::sse_encode(self.use_default_external_input_parsers, serializer); <bool>::sse_encode(self.use_default_external_input_parsers, serializer);
<Option<u32>>::sse_encode(self.onchain_fee_rate_leeway_sat_per_vbyte, serializer); <Option<u32>>::sse_encode(self.onchain_fee_rate_leeway_sat_per_vbyte, serializer);
<Option<Vec<crate::model::AssetMetadata>>>::sse_encode(self.asset_metadata, serializer); <Option<Vec<crate::model::AssetMetadata>>>::sse_encode(self.asset_metadata, serializer);
<Option<String>>::sse_encode(self.sideswap_api_key, serializer);
} }
} }
@@ -8605,10 +8630,12 @@ impl SseEncode for crate::model::PayAmount {
crate::model::PayAmount::Asset { crate::model::PayAmount::Asset {
asset_id, asset_id,
receiver_amount, receiver_amount,
estimate_asset_fees,
} => { } => {
<i32>::sse_encode(1, serializer); <i32>::sse_encode(1, serializer);
<String>::sse_encode(asset_id, serializer); <String>::sse_encode(asset_id, serializer);
<f64>::sse_encode(receiver_amount, serializer); <f64>::sse_encode(receiver_amount, serializer);
<Option<bool>>::sse_encode(estimate_asset_fees, serializer);
} }
crate::model::PayAmount::Drain => { crate::model::PayAmount::Drain => {
<i32>::sse_encode(2, serializer); <i32>::sse_encode(2, serializer);
@@ -8967,7 +8994,8 @@ impl SseEncode for crate::model::PrepareSendResponse {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<crate::model::SendDestination>::sse_encode(self.destination, serializer); <crate::model::SendDestination>::sse_encode(self.destination, serializer);
<u64>::sse_encode(self.fees_sat, serializer); <Option<u64>>::sse_encode(self.fees_sat, serializer);
<Option<f64>>::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 // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<crate::model::PrepareSendResponse>::sse_encode(self.prepare_response, serializer); <crate::model::PrepareSendResponse>::sse_encode(self.prepare_response, serializer);
<Option<bool>>::sse_encode(self.use_asset_fees, serializer);
} }
} }
@@ -9525,6 +9554,7 @@ mod io {
name: self.name.cst_decode(), name: self.name.cst_decode(),
ticker: self.ticker.cst_decode(), ticker: self.ticker.cst_decode(),
amount: self.amount.cst_decode(), amount: self.amount.cst_decode(),
fees: self.fees.cst_decode(),
} }
} }
} }
@@ -9536,6 +9566,7 @@ mod io {
name: self.name.cst_decode(), name: self.name.cst_decode(),
ticker: self.ticker.cst_decode(), ticker: self.ticker.cst_decode(),
precision: self.precision.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 .onchain_fee_rate_leeway_sat_per_vbyte
.cst_decode(), .cst_decode(),
asset_metadata: self.asset_metadata.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 { crate::model::PayAmount::Asset {
asset_id: ans.asset_id.cst_decode(), asset_id: ans.asset_id.cst_decode(),
receiver_amount: ans.receiver_amount.cst_decode(), receiver_amount: ans.receiver_amount.cst_decode(),
estimate_asset_fees: ans.estimate_asset_fees.cst_decode(),
} }
} }
2 => crate::model::PayAmount::Drain, 2 => crate::model::PayAmount::Drain,
@@ -11166,6 +11199,7 @@ mod io {
crate::model::PrepareSendResponse { crate::model::PrepareSendResponse {
destination: self.destination.cst_decode(), destination: self.destination.cst_decode(),
fees_sat: self.fees_sat.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 { fn cst_decode(self) -> crate::model::SendPaymentRequest {
crate::model::SendPaymentRequest { crate::model::SendPaymentRequest {
prepare_response: self.prepare_response.cst_decode(), 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(), name: core::ptr::null_mut(),
ticker: core::ptr::null_mut(), ticker: core::ptr::null_mut(),
amount: Default::default(), amount: Default::default(),
fees: core::ptr::null_mut(),
} }
} }
} }
@@ -11623,6 +11659,7 @@ mod io {
name: core::ptr::null_mut(), name: core::ptr::null_mut(),
ticker: core::ptr::null_mut(), ticker: core::ptr::null_mut(),
precision: Default::default(), precision: Default::default(),
fiat_id: core::ptr::null_mut(),
} }
} }
} }
@@ -11752,6 +11789,7 @@ mod io {
use_default_external_input_parsers: Default::default(), use_default_external_input_parsers: Default::default(),
onchain_fee_rate_leeway_sat_per_vbyte: core::ptr::null_mut(), onchain_fee_rate_leeway_sat_per_vbyte: core::ptr::null_mut(),
asset_metadata: 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 { fn new_with_null_ptr() -> Self {
Self { Self {
destination: Default::default(), 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 { fn new_with_null_ptr() -> Self {
Self { Self {
prepare_response: Default::default(), 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, name: *mut wire_cst_list_prim_u_8_strict,
ticker: *mut wire_cst_list_prim_u_8_strict, ticker: *mut wire_cst_list_prim_u_8_strict,
amount: f64, amount: f64,
fees: *mut f64,
} }
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -13945,6 +13986,7 @@ mod io {
name: *mut wire_cst_list_prim_u_8_strict, name: *mut wire_cst_list_prim_u_8_strict,
ticker: *mut wire_cst_list_prim_u_8_strict, ticker: *mut wire_cst_list_prim_u_8_strict,
precision: u8, precision: u8,
fiat_id: *mut wire_cst_list_prim_u_8_strict,
} }
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -14029,6 +14071,7 @@ mod io {
use_default_external_input_parsers: bool, use_default_external_input_parsers: bool,
onchain_fee_rate_leeway_sat_per_vbyte: *mut u32, onchain_fee_rate_leeway_sat_per_vbyte: *mut u32,
asset_metadata: *mut wire_cst_list_asset_metadata, asset_metadata: *mut wire_cst_list_asset_metadata,
sideswap_api_key: *mut wire_cst_list_prim_u_8_strict,
} }
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -14722,6 +14765,7 @@ mod io {
pub struct wire_cst_PayAmount_Asset { pub struct wire_cst_PayAmount_Asset {
asset_id: *mut wire_cst_list_prim_u_8_strict, asset_id: *mut wire_cst_list_prim_u_8_strict,
receiver_amount: f64, receiver_amount: f64,
estimate_asset_fees: *mut bool,
} }
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -14952,7 +14996,8 @@ mod io {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct wire_cst_prepare_send_response { pub struct wire_cst_prepare_send_response {
destination: wire_cst_send_destination, destination: wire_cst_send_destination,
fees_sat: u64, fees_sat: *mut u64,
estimated_asset_fees: *mut f64,
} }
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -15171,6 +15216,7 @@ mod io {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct wire_cst_send_payment_request { pub struct wire_cst_send_payment_request {
prepare_response: wire_cst_prepare_send_response, prepare_response: wire_cst_prepare_send_response,
use_asset_fees: *mut bool,
} }
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]

View File

@@ -177,6 +177,7 @@ pub(crate) mod lnurl;
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
pub mod logger; pub mod logger;
pub mod model; pub mod model;
pub(crate) mod payjoin;
pub mod persist; pub mod persist;
pub mod receive_swap; pub mod receive_swap;
pub(crate) mod recover; pub(crate) mod recover;

View File

@@ -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 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"; pub const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology";
const SIDESWAP_API_KEY: &str = "97fb6a1dfa37ee6656af92ef79675cc03b8ac4c52e04655f41edbd5af888dcc2";
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub enum BlockchainExplorer { pub enum BlockchainExplorer {
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] #[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. /// See [AssetMetadata] for more details on how define asset metadata.
/// By default the asset metadata for Liquid Bitcoin and Tether USD are included. /// By default the asset metadata for Liquid Bitcoin and Tether USD are included.
pub asset_metadata: Option<Vec<AssetMetadata>>, pub asset_metadata: Option<Vec<AssetMetadata>>,
/// The SideSwap API key used for making requests to the SideSwap payjoin service
pub sideswap_api_key: Option<String>,
} }
impl Config { impl Config {
@@ -117,6 +121,7 @@ impl Config {
use_default_external_input_parsers: true, use_default_external_input_parsers: true,
onchain_fee_rate_leeway_sat_per_vbyte: None, onchain_fee_rate_leeway_sat_per_vbyte: None,
asset_metadata: 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, use_default_external_input_parsers: true,
onchain_fee_rate_leeway_sat_per_vbyte: None, onchain_fee_rate_leeway_sat_per_vbyte: None,
asset_metadata: 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, use_default_external_input_parsers: true,
onchain_fee_rate_leeway_sat_per_vbyte: None, onchain_fee_rate_leeway_sat_per_vbyte: None,
asset_metadata: 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, use_default_external_input_parsers: true,
onchain_fee_rate_leeway_sat_per_vbyte: None, onchain_fee_rate_leeway_sat_per_vbyte: None,
asset_metadata: 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, use_default_external_input_parsers: true,
onchain_fee_rate_leeway_sat_per_vbyte: None, onchain_fee_rate_leeway_sat_per_vbyte: None,
asset_metadata: None, asset_metadata: None,
sideswap_api_key: None,
} }
} }
@@ -235,6 +244,7 @@ impl Config {
use_default_external_input_parsers: true, use_default_external_input_parsers: true,
onchain_fee_rate_leeway_sat_per_vbyte: None, onchain_fee_rate_leeway_sat_per_vbyte: None,
asset_metadata: 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 /// Network chosen for this Liquid SDK instance. Note that it represents both the Liquid and the
/// Bitcoin network used. /// Bitcoin network used.
#[derive(Debug, Copy, Clone, PartialEq, Serialize)] #[derive(Debug, Display, Copy, Clone, PartialEq, Serialize)]
pub enum LiquidNetwork { pub enum LiquidNetwork {
/// Mainnet Bitcoin and Liquid chains /// Mainnet Bitcoin and Liquid chains
Mainnet, Mainnet,
@@ -707,13 +717,20 @@ pub enum SendDestination {
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
pub struct PrepareSendResponse { pub struct PrepareSendResponse {
pub destination: SendDestination, 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<u64>,
/// 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<f64>,
} }
/// An argument when calling [crate::sdk::LiquidSdk::send_payment]. /// An argument when calling [crate::sdk::LiquidSdk::send_payment].
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct SendPaymentRequest { pub struct SendPaymentRequest {
pub prepare_response: PrepareSendResponse, pub prepare_response: PrepareSendResponse,
pub use_asset_fees: Option<bool>,
} }
/// Returned when calling [crate::sdk::LiquidSdk::send_payment]. /// Returned when calling [crate::sdk::LiquidSdk::send_payment].
@@ -732,6 +749,7 @@ pub enum PayAmount {
Asset { Asset {
asset_id: String, asset_id: String,
receiver_amount: f64, receiver_amount: f64,
estimate_asset_fees: Option<bool>,
}, },
/// Indicates that all available Bitcoin funds should be sent /// Indicates that all available Bitcoin funds should be sent
@@ -837,9 +855,10 @@ impl WalletInfo {
&self, &self,
network: LiquidNetwork, network: LiquidNetwork,
amount_sat: u64, amount_sat: u64,
fees_sat: u64, fees_sat: Option<u64>,
asset_id: &str, asset_id: &str,
) -> Result<(), PaymentError> { ) -> Result<(), PaymentError> {
let fees_sat = fees_sat.unwrap_or(0);
if asset_id.eq(&utils::lbtc_asset_id(network).to_string()) { if asset_id.eq(&utils::lbtc_asset_id(network).to_string()) {
ensure_sdk!( ensure_sdk!(
amount_sat + fees_sat <= self.balance_sat, amount_sat + fees_sat <= self.balance_sat,
@@ -1754,6 +1773,8 @@ pub struct AssetMetadata {
/// The precision used to display the asset amount. /// The precision used to display the asset amount.
/// For example, precision of 2 shifts the decimal 2 places left from the satoshi amount. /// For example, precision of 2 shifts the decimal 2 places left from the satoshi amount.
pub precision: u8, pub precision: u8,
/// The optional ID of the fiat currency used to represent the asset
pub fiat_id: Option<String>,
} }
impl AssetMetadata { impl AssetMetadata {
@@ -1777,6 +1798,9 @@ pub struct AssetInfo {
/// The amount calculated from the satoshi amount of the transaction, having its /// The amount calculated from the satoshi amount of the transaction, having its
/// decimal shifted to the left by the [precision](AssetMetadata::precision) /// decimal shifted to the left by the [precision](AssetMetadata::precision)
pub amount: f64, 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<f64>,
} }
/// The specific details of a payment, depending on its type /// 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), s.payer_amount_sat.saturating_sub(s.receiver_amount_sat),
), ),
}, },
None => match tx.payment_type { None => {
PaymentType::Receive => (tx.amount, 0), let (amount_sat, fees_sat) = match tx.payment_type {
PaymentType::Send => (tx.amount, tx.fees_sat), 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 { Payment {
tx_id: Some(tx.tx_id), tx_id: Some(tx.tx_id),

View File

@@ -0,0 +1,73 @@
use lwk_wollet::{bitcoin, elements};
use sdk_common::prelude::ServiceConnectivityError;
use crate::error::PaymentError;
pub type PayjoinResult<T, E = PayjoinError> = Result<T, E>;
#[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<S: AsRef<str>>(err: S) -> Self {
Self::Generic(err.as_ref().to_string())
}
pub(crate) fn service_connectivity<S: AsRef<str>>(err: S) -> Self {
Self::ServiceConnectivity(err.as_ref().to_string())
}
}
impl From<anyhow::Error> for PayjoinError {
fn from(err: anyhow::Error) -> Self {
Self::Generic(err.to_string())
}
}
impl From<bitcoin::base64::DecodeError> for PayjoinError {
fn from(err: bitcoin::base64::DecodeError) -> Self {
Self::Generic(err.to_string())
}
}
impl From<elements::encode::Error> for PayjoinError {
fn from(err: elements::encode::Error) -> Self {
Self::Generic(err.to_string())
}
}
impl From<elements::hashes::hex::HexToArrayError> for PayjoinError {
fn from(err: elements::hashes::hex::HexToArrayError) -> Self {
Self::Generic(err.to_string())
}
}
impl From<PaymentError> for PayjoinError {
fn from(value: PaymentError) -> Self {
match value {
PaymentError::InsufficientFunds => Self::InsufficientFunds,
_ => Self::Generic(value.to_string()),
}
}
}
impl From<serde_json::error::Error> for PayjoinError {
fn from(value: serde_json::error::Error) -> Self {
Self::Generic(value.to_string())
}
}
impl From<ServiceConnectivityError> for PayjoinError {
fn from(value: ServiceConnectivityError) -> Self {
Self::ServiceConnectivity(value.err)
}
}

View File

@@ -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<Vec<AcceptedAsset>>;
/// Estimate the fee for a payjoin transaction
async fn estimate_payjoin_tx_fee(&self, asset_id: &str, amount_sat: u64) -> PayjoinResult<f64>;
/// 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)>;
}

View File

@@ -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<AcceptedAsset>,
}
#[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<String>,
}
#[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<Utxo>,
}
#[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,
}

View File

@@ -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
);
}
}

View File

@@ -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::<Vec<_>>();
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::<Vec<_>>();
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(())
}

View File

@@ -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<PsetInput>,
pub outputs: Vec<PsetOutput>,
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<Output> {
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<PartiallySignedTransaction> {
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)
}

View File

@@ -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());
}
}

View File

@@ -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<dyn FiatAPI>,
persister: Arc<Persister>,
onchain_wallet: Arc<dyn OnchainWallet>,
rest_client: Arc<dyn RestClient>,
accepted_assets: OnceCell<AcceptedAssetsResponse>,
}
impl SideSwapPayjoinService {
pub fn new(
config: Config,
fiat_api: Arc<dyn FiatAPI>,
persister: Arc<Persister>,
onchain_wallet: Arc<dyn OnchainWallet>,
rest_client: Arc<dyn RestClient>,
) -> 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<I: Serialize, O: DeserializeOwned>(&self, body: &I) -> PayjoinResult<O> {
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<Vec<AcceptedAsset>> {
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<f64> {
// 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::<Vec<_>>();
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::<PartiallySignedTransaction>(
&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<PartiallySignedTransaction> {
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<Utxo>, in_outs: Vec<InOut>) -> PayjoinResult<Vec<PsetInput>> {
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<Persister>,
) -> Result<(Arc<MockWallet>, Arc<MockRestClient>, SideSwapPayjoinService)> {
let config = Config::testnet_esplora(None);
let breez_server = Arc::new(BreezServer::new(STAGING_BREEZSERVER_URL.to_string(), None)?);
let signer: Arc<Box<dyn Signer>> = 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<u64>) -> Vec<WalletTxOut> {
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(())
}
}

View File

@@ -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<InOut>,
pub user_outputs: Vec<InOut>,
}
pub(crate) struct AssetSelectResult {
pub asset_inputs: Vec<InOut>,
pub user_outputs: Vec<InOut>,
pub change_outputs: Vec<InOut>,
pub user_output_amounts: BTreeMap<AssetId, u64>,
}
pub(crate) fn asset_select(
AssetSelectRequest {
fee_asset,
wallet_utxos,
user_outputs,
}: AssetSelectRequest,
) -> Result<AssetSelectResult> {
let mut user_output_amounts = BTreeMap::<AssetId, u64>::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::<InOut>::new();
let mut change_outputs = Vec::<InOut>::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::<Vec<_>>();
let available = wallet_utxo.iter().sum::<u64>();
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,
})
}

View File

@@ -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<InOut>,
pub server_utxos: Vec<InOut>,
pub user_outputs: Vec<InOut>,
}
#[derive(Debug)]
pub(crate) struct UtxoSelectResult {
pub user_inputs: Vec<InOut>,
pub client_inputs: Vec<InOut>,
pub server_inputs: Vec<InOut>,
pub user_outputs: Vec<InOut>,
pub change_outputs: Vec<InOut>,
pub server_fee: InOut,
pub server_change: Option<InOut>,
pub fee_change: Option<InOut>,
pub network_fee: InOut,
pub cost: u64,
}
pub(crate) fn utxo_select(req: UtxoSelectRequest) -> Result<UtxoSelectResult> {
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<Vec<u64>> {
let selected_utxos = utxos
.iter()
.copied()
.take(target_utxo_count)
.collect::<Vec<_>>();
let selected_value = selected_utxos.iter().sum::<u64>();
if selected_value < target_value {
None
} else {
Some(selected_utxos)
}
}
pub(crate) fn utxo_select_basic(target_value: u64, utxos: &[u64]) -> Option<Vec<u64>> {
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<Vec<u64>> {
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<Vec<u64>> {
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<usize> = vec![];
let mut best_selection: Option<Vec<usize>> = None;
let upper_bound = target_value + upper_bound_delta;
let mut available_value = utxos.iter().sum::<u64>();
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<UtxoSelectResult> {
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::<Vec<_>>();
let mut server_utxos = server_utxos
.iter()
.map(|utxo| utxo.value)
.collect::<Vec<_>>();
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<UtxoSelectResult> = 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::<u64>();
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::<u64>();
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::<AssetId, u64>::new();
let mut outputs = BTreeMap::<AssetId, u64>::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(())
}

View File

@@ -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<Vec<u64>> = utxo_select_best(450, &utxos);
assert!(selected.is_some());
assert_eq!(selected.unwrap().iter().sum::<u64>(), 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::<AssetId, u64>::new();
let mut output_sum_by_asset = BTreeMap::<AssetId, u64>::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);
}
}

View File

@@ -13,8 +13,8 @@ impl Persister {
if let Some(asset_metadata) = asset_metadata { if let Some(asset_metadata) = asset_metadata {
for am in asset_metadata { for am in asset_metadata {
con.execute( con.execute(
"INSERT INTO asset_metadata (asset_id, name, ticker, precision) VALUES (?, ?, ?, ?)", "INSERT INTO asset_metadata (asset_id, name, ticker, precision, fiat_id) VALUES (?, ?, ?, ?, ?)",
(am.asset_id, am.name, am.ticker, am.precision), (am.asset_id, am.name, am.ticker, am.precision, am.fiat_id),
)?; )?;
} }
} }
@@ -28,7 +28,8 @@ impl Persister {
"SELECT asset_id, "SELECT asset_id,
name, name,
ticker, ticker,
precision precision,
fiat_id
FROM asset_metadata", FROM asset_metadata",
)?; )?;
let asset_metadata: Vec<AssetMetadata> = stmt let asset_metadata: Vec<AssetMetadata> = stmt
@@ -44,7 +45,8 @@ impl Persister {
"SELECT asset_id, "SELECT asset_id,
name, name,
ticker, ticker,
precision precision,
fiat_id
FROM asset_metadata FROM asset_metadata
WHERE asset_id = ?", WHERE asset_id = ?",
)?; )?;
@@ -59,6 +61,7 @@ impl Persister {
name: row.get(1)?, name: row.get(1)?,
ticker: row.get(2)?, ticker: row.get(2)?,
precision: row.get(3)?, precision: row.get(3)?,
fiat_id: row.get(4)?,
}) })
} }
} }

View File

@@ -25,6 +25,11 @@ pub(crate) fn current_migrations(network: LiquidNetwork) -> Vec<&'static str> {
('5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225', 'Regtest Bitcoin', 'BTC', 8, 1); ('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![ vec![
"CREATE TABLE IF NOT EXISTS receive_swaps ( "CREATE TABLE IF NOT EXISTS receive_swaps (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
@@ -315,5 +320,8 @@ pub(crate) fn current_migrations(network: LiquidNetwork) -> Vec<&'static str> {
WHERE id = NEW.id; WHERE id = NEW.id;
END; END;
", ",
"ALTER TABLE asset_metadata ADD COLUMN fiat_id TEXT;",
update_asset_metadata_fiat_id,
"ALTER TABLE payment_details ADD COLUMN asset_fees INTEGER;",
] ]
} }

View File

@@ -341,15 +341,17 @@ impl Persister {
destination, destination,
description, description,
lnurl_info_json, lnurl_info_json,
bip353_address bip353_address,
asset_fees
) )
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (tx_id) ON CONFLICT (tx_id)
DO UPDATE SET DO UPDATE SET
{destination_update} {destination_update}
description = COALESCE(excluded.description, description), description = COALESCE(excluded.description, description),
lnurl_info_json = COALESCE(excluded.lnurl_info_json, lnurl_info_json), 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() .as_ref()
.map(|info| serde_json::to_string(&info).ok()), .map(|info| serde_json::to_string(&info).ok()),
&payment_tx_details.bip353_address, &payment_tx_details.bip353_address,
&payment_tx_details.asset_fees,
), ),
)?; )?;
Ok(()) Ok(())
@@ -389,7 +392,7 @@ impl Persister {
pub(crate) fn get_payment_details(&self, tx_id: &str) -> Result<Option<PaymentTxDetails>> { pub(crate) fn get_payment_details(&self, tx_id: &str) -> Result<Option<PaymentTxDetails>> {
let con = self.get_connection()?; let con = self.get_connection()?;
let mut stmt = con.prepare( 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 FROM payment_details
WHERE tx_id = ?", WHERE tx_id = ?",
)?; )?;
@@ -398,6 +401,7 @@ impl Persister {
let description = row.get(1)?; let description = row.get(1)?;
let maybe_lnurl_info_json: Option<String> = row.get(2)?; let maybe_lnurl_info_json: Option<String> = row.get(2)?;
let maybe_bip353_address = row.get(3)?; let maybe_bip353_address = row.get(3)?;
let maybe_asset_fees = row.get(4)?;
Ok(PaymentTxDetails { Ok(PaymentTxDetails {
tx_id: tx_id.to_string(), tx_id: tx_id.to_string(),
destination, destination,
@@ -405,6 +409,7 @@ impl Persister {
lnurl_info: maybe_lnurl_info_json lnurl_info: maybe_lnurl_info_json
.and_then(|info| serde_json::from_str::<LnUrlInfo>(&info).ok()), .and_then(|info| serde_json::from_str::<LnUrlInfo>(&info).ok()),
bip353_address: maybe_bip353_address, bip353_address: maybe_bip353_address,
asset_fees: maybe_asset_fees,
}) })
}); });
Ok(res.ok()) Ok(res.ok())
@@ -500,6 +505,7 @@ impl Persister {
pd.description, pd.description,
pd.lnurl_info_json, pd.lnurl_info_json,
pd.bip353_address, pd.bip353_address,
pd.asset_fees,
am.name, am.name,
am.ticker, am.ticker,
am.precision am.precision
@@ -622,10 +628,11 @@ impl Persister {
let maybe_payment_details_lnurl_info: Option<LnUrlInfo> = let maybe_payment_details_lnurl_info: Option<LnUrlInfo> =
maybe_payment_details_lnurl_info_json.and_then(|info| serde_json::from_str(&info).ok()); maybe_payment_details_lnurl_info_json.and_then(|info| serde_json::from_str(&info).ok());
let maybe_payment_details_bip353_address: Option<String> = row.get(55)?; let maybe_payment_details_bip353_address: Option<String> = row.get(55)?;
let maybe_payment_details_asset_fees: Option<u64> = row.get(56)?;
let maybe_asset_metadata_name: Option<String> = row.get(56)?; let maybe_asset_metadata_name: Option<String> = row.get(57)?;
let maybe_asset_metadata_ticker: Option<String> = row.get(57)?; let maybe_asset_metadata_ticker: Option<String> = row.get(58)?;
let maybe_asset_metadata_precision: Option<u8> = row.get(58)?; let maybe_asset_metadata_precision: Option<u8> = row.get(59)?;
let (swap, payment_type) = match maybe_receive_swap_id { let (swap, payment_type) = match maybe_receive_swap_id {
Some(receive_swap_id) => { Some(receive_swap_id) => {
@@ -844,12 +851,21 @@ impl Persister {
name: name.clone(), name: name.clone(),
ticker: ticker.clone(), ticker: ticker.clone(),
precision, 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 { Some(AssetInfo {
name, name,
ticker, ticker,
amount: asset_metadata.amount_from_sat(amount), amount: asset_metadata.amount_from_sat(amount),
fees,
}) })
} }
_ => None, _ => None,

View File

@@ -7,4 +7,5 @@ pub(crate) struct PaymentTxDetails {
pub(crate) description: Option<String>, pub(crate) description: Option<String>,
pub(crate) lnurl_info: Option<LnUrlInfo>, pub(crate) lnurl_info: Option<LnUrlInfo>,
pub(crate) bip353_address: Option<String>, pub(crate) bip353_address: Option<String>,
pub(crate) asset_fees: Option<u64>,
} }

View File

@@ -37,6 +37,7 @@ use crate::error::SdkError;
use crate::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription}; use crate::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription};
use crate::model::PaymentState::*; use crate::model::PaymentState::*;
use crate::model::Signer; use crate::model::Signer;
use crate::payjoin::{side_swap::SideSwapPayjoinService, PayjoinService};
use crate::receive_swap::ReceiveSwapHandler; use crate::receive_swap::ReceiveSwapHandler;
use crate::send_swap::SendSwapHandler; use crate::send_swap::SendSwapHandler;
use crate::swapper::SubscriptionHandler; use crate::swapper::SubscriptionHandler;
@@ -77,6 +78,7 @@ pub struct LiquidSdkBuilder {
bitcoin_chain_service: Option<Arc<dyn BitcoinChainService>>, bitcoin_chain_service: Option<Arc<dyn BitcoinChainService>>,
liquid_chain_service: Option<Arc<dyn LiquidChainService>>, liquid_chain_service: Option<Arc<dyn LiquidChainService>>,
onchain_wallet: Option<Arc<dyn OnchainWallet>>, onchain_wallet: Option<Arc<dyn OnchainWallet>>,
payjoin_service: Option<Arc<dyn PayjoinService>>,
persister: Option<Arc<Persister>>, persister: Option<Arc<Persister>>,
recoverer: Option<Arc<Recoverer>>, recoverer: Option<Arc<Recoverer>>,
rest_client: Option<Arc<dyn RestClient>>, rest_client: Option<Arc<dyn RestClient>>,
@@ -100,6 +102,7 @@ impl LiquidSdkBuilder {
bitcoin_chain_service: None, bitcoin_chain_service: None,
liquid_chain_service: None, liquid_chain_service: None,
onchain_wallet: None, onchain_wallet: None,
payjoin_service: None,
persister: None, persister: None,
recoverer: None, recoverer: None,
rest_client: None, rest_client: None,
@@ -135,6 +138,11 @@ impl LiquidSdkBuilder {
self self
} }
pub fn payjoin_service(&mut self, payjoin_service: Arc<dyn PayjoinService>) -> &mut Self {
self.payjoin_service = Some(payjoin_service.clone());
self
}
pub fn persister(&mut self, persister: Arc<Persister>) -> &mut Self { pub fn persister(&mut self, persister: Arc<Persister>) -> &mut Self {
self.persister = Some(persister.clone()); self.persister = Some(persister.clone());
self self
@@ -307,6 +315,17 @@ impl LiquidSdkBuilder {
bitcoin_chain_service.clone(), 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( let buy_bitcoin_service = Arc::new(BuyBitcoinService::new(
self.config.clone(), self.config.clone(),
self.breez_server.clone(), self.breez_server.clone(),
@@ -334,6 +353,7 @@ impl LiquidSdkBuilder {
receive_swap_handler, receive_swap_handler,
sync_service, sync_service,
chain_swap_handler, chain_swap_handler,
payjoin_service,
buy_bitcoin_service, buy_bitcoin_service,
external_input_parsers, external_input_parsers,
}); });
@@ -361,6 +381,7 @@ pub struct LiquidSdk {
pub(crate) sync_service: Option<Arc<SyncService>>, pub(crate) sync_service: Option<Arc<SyncService>>,
pub(crate) receive_swap_handler: ReceiveSwapHandler, pub(crate) receive_swap_handler: ReceiveSwapHandler,
pub(crate) chain_swap_handler: Arc<ChainSwapHandler>, pub(crate) chain_swap_handler: Arc<ChainSwapHandler>,
pub(crate) payjoin_service: Arc<dyn PayjoinService>,
pub(crate) buy_bitcoin_service: Arc<dyn BuyBitcoinApi>, pub(crate) buy_bitcoin_service: Arc<dyn BuyBitcoinApi>,
pub(crate) external_input_parsers: Vec<ExternalInputParser>, pub(crate) external_input_parsers: Vec<ExternalInputParser>,
} }
@@ -1135,7 +1156,11 @@ impl LiquidSdk {
/// # Returns /// # Returns
/// Returns a [PrepareSendResponse] containing: /// Returns a [PrepareSendResponse] containing:
/// * `destination` - the parsed destination, of type [SendDestination] /// * `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( pub async fn prepare_send_payment(
&self, &self,
req: &PrepareSendRequest, req: &PrepareSendRequest,
@@ -1144,6 +1169,7 @@ impl LiquidSdk {
let get_info_res = self.get_info().await?; let get_info_res = self.get_info().await?;
let fees_sat; let fees_sat;
let estimated_asset_fees;
let receiver_amount_sat; let receiver_amount_sat;
let asset_id; let asset_id;
let payment_destination; let payment_destination;
@@ -1167,6 +1193,7 @@ impl LiquidSdk {
PayAmount::Asset { PayAmount::Asset {
asset_id, asset_id,
receiver_amount: amount, 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 => { PayAmount::Drain => {
ensure_sdk!( ensure_sdk!(
get_info_res.wallet_info.pending_receive_sat == 0 get_info_res.wallet_info.pending_receive_sat == 0
@@ -1210,7 +1242,8 @@ impl LiquidSdk {
( (
self.config.lbtc_asset_id(), self.config.lbtc_asset_id(),
drain_amount_sat, drain_amount_sat,
drain_fees_sat, Some(drain_fees_sat),
None,
) )
} }
PayAmount::Bitcoin { PayAmount::Bitcoin {
@@ -1224,26 +1257,47 @@ impl LiquidSdk {
&asset_id, &asset_id,
) )
.await?; .await?;
(asset_id, receiver_amount_sat, fees_sat) (asset_id, receiver_amount_sat, Some(fees_sat), None)
} }
PayAmount::Asset { PayAmount::Asset {
asset_id, asset_id,
receiver_amount, 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( let asset_metadata = self.persister.get_asset_metadata(&asset_id)?.ok_or(
PaymentError::AssetError { PaymentError::AssetError {
err: format!("Asset {asset_id} is not supported"), err: format!("Asset {asset_id} is not supported"),
}, },
)?; )?;
let receiver_amount_sat = asset_metadata.amount_to_sat(receiver_amount); 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( .estimate_onchain_tx_or_drain_tx_fee(
receiver_amount_sat, receiver_amount_sat,
&liquid_address_data.address, &liquid_address_data.address,
&asset_id, &asset_id,
) )
.await?; .await;
(asset_id, receiver_amount_sat, fees_sat) 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? .await?
.map(|(address, _)| address); .map(|(address, _)| address);
asset_id = self.config.lbtc_asset_id(); asset_id = self.config.lbtc_asset_id();
estimated_asset_fees = None;
(receiver_amount_sat, fees_sat, payment_destination) = (receiver_amount_sat, fees_sat, payment_destination) =
match (mrh_address.clone(), req.amount.clone()) { match (mrh_address.clone(), req.amount.clone()) {
(Some(lbtc_address), Some(PayAmount::Drain)) => { (Some(lbtc_address), Some(PayAmount::Drain)) => {
@@ -1303,7 +1358,7 @@ impl LiquidSdk {
}, },
bip353_address: None, bip353_address: None,
}; };
(drain_amount_sat, drain_fees_sat, payment_destination) (drain_amount_sat, Some(drain_fees_sat), payment_destination)
} }
(Some(lbtc_address), _) => { (Some(lbtc_address), _) => {
// The BOLT11 invoice has an MRH but no drain is requested, // The BOLT11 invoice has an MRH but no drain is requested,
@@ -1317,7 +1372,7 @@ impl LiquidSdk {
.await?; .await?;
( (
invoice_amount_sat, invoice_amount_sat,
fees_sat, Some(fees_sat),
SendDestination::Bolt11 { SendDestination::Bolt11 {
invoice, invoice,
bip353_address: None, bip353_address: None,
@@ -1334,7 +1389,7 @@ impl LiquidSdk {
let fees_sat = boltz_fees_total + lockup_fees_sat; let fees_sat = boltz_fees_total + lockup_fees_sat;
( (
invoice_amount_sat, invoice_amount_sat,
fees_sat, Some(fees_sat),
SendDestination::Bolt11 { SendDestination::Bolt11 {
invoice, invoice,
bip353_address: None, bip353_address: None,
@@ -1371,7 +1426,8 @@ impl LiquidSdk {
.estimate_lockup_tx_or_drain_tx_fee(receiver_amount_sat + boltz_fees_total) .estimate_lockup_tx_or_drain_tx_fee(receiver_amount_sat + boltz_fees_total)
.await?; .await?;
asset_id = self.config.lbtc_asset_id(); 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 { payment_destination = SendDestination::Bolt12 {
offer, offer,
@@ -1394,6 +1450,7 @@ impl LiquidSdk {
Ok(PrepareSendResponse { Ok(PrepareSendResponse {
destination: payment_destination, destination: payment_destination,
fees_sat, fees_sat,
estimated_asset_fees,
}) })
} }
@@ -1428,6 +1485,7 @@ impl LiquidSdk {
let PrepareSendResponse { let PrepareSendResponse {
fees_sat, fees_sat,
destination: payment_destination, destination: payment_destination,
..
} = &req.prepare_response; } = &req.prepare_response;
match payment_destination { match payment_destination {
@@ -1435,6 +1493,7 @@ impl LiquidSdk {
address_data: liquid_address_data, address_data: liquid_address_data,
bip353_address, bip353_address,
} => { } => {
let asset_pay_fees = req.use_asset_fees.unwrap_or_default();
let Some(amount_sat) = liquid_address_data.amount_sat else { let Some(amount_sat) = liquid_address_data.amount_sat else {
return Err(PaymentError::AmountMissing { return Err(PaymentError::AmountMissing {
err: "Amount must be set when paying to a Liquid address".to_string(), err: "Amount must be set when paying to a Liquid address".to_string(),
@@ -1466,9 +1525,16 @@ impl LiquidSdk {
*fees_sat, *fees_sat,
asset_id, asset_id,
)?; )?;
let mut response = self
.pay_liquid(liquid_address_data.clone(), amount_sat, *fees_sat, true) let mut response = if asset_pay_fees {
.await?; 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)?; self.insert_bip353_payment_details(bip353_address, &mut response)?;
Ok(response) Ok(response)
} }
@@ -1476,7 +1542,8 @@ impl LiquidSdk {
invoice, invoice,
bip353_address, 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)?; self.insert_bip353_payment_details(bip353_address, &mut response)?;
Ok(response) Ok(response)
} }
@@ -1485,12 +1552,13 @@ impl LiquidSdk {
receiver_amount_sat, receiver_amount_sat,
bip353_address, bip353_address,
} => { } => {
let fees_sat = fees_sat.ok_or(PaymentError::InsufficientFunds)?;
let bolt12_invoice = self let bolt12_invoice = self
.swapper .swapper
.get_bolt12_invoice(&offer.offer, *receiver_amount_sat) .get_bolt12_invoice(&offer.offer, *receiver_amount_sat)
.await?; .await?;
let mut response = self 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?; .await?;
self.insert_bip353_payment_details(bip353_address, &mut response)?; self.insert_bip353_payment_details(bip353_address, &mut response)?;
Ok(response) Ok(response)
@@ -1514,6 +1582,7 @@ impl LiquidSdk {
description: None, description: None,
lnurl_info: None, lnurl_info: None,
bip353_address: bip353_address.clone(), bip353_address: bip353_address.clone(),
asset_fees: None,
})?; })?;
// Get the payment with the bip353_address details // Get the payment with the bip353_address details
if let Some(payment) = self.persister.get_payment(tx_id)? { if let Some(payment) = self.persister.get_payment(tx_id)? {
@@ -1608,7 +1677,7 @@ impl LiquidSdk {
.await .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( async fn pay_liquid(
&self, &self,
address_data: LiquidAddressData, address_data: LiquidAddressData,
@@ -1646,7 +1715,7 @@ impl LiquidSdk {
ensure_sdk!(tx_fees_sat <= fees_sat, PaymentError::InvalidOrExpiredFees); ensure_sdk!(tx_fees_sat <= fees_sat, PaymentError::InvalidOrExpiredFees);
info!( 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(); let tx_id = self.liquid_chain_service.broadcast(&tx).await?.to_string();
@@ -1685,6 +1754,87 @@ impl LiquidSdk {
name: am.name.clone(), name: am.name.clone(),
ticker: am.ticker.clone(), ticker: am.ticker.clone(),
amount: am.amount_from_sat(receiver_amount_sat), 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<SendPaymentResponse, PaymentError> {
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::<u64>();
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 { let payment_details = PaymentDetails::Liquid {
asset_id, asset_id,
@@ -3513,10 +3663,13 @@ impl LiquidSdk {
} }
destination => destination, destination => destination,
}; };
let fees_sat = prepare_response
.fees_sat
.ok_or(PaymentError::InsufficientFunds)?;
Ok(PrepareLnUrlPayResponse { Ok(PrepareLnUrlPayResponse {
destination, destination,
fees_sat: prepare_response.fees_sat, fees_sat,
data: req.data, data: req.data,
comment: req.comment, comment: req.comment,
success_action: data.success_action, success_action: data.success_action,
@@ -3546,8 +3699,10 @@ impl LiquidSdk {
.send_payment(&SendPaymentRequest { .send_payment(&SendPaymentRequest {
prepare_response: PrepareSendResponse { prepare_response: PrepareSendResponse {
destination: prepare_response.destination.clone(), 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 .await
.map_err(|e| LnUrlPayError::Generic { err: e.to_string() })? .map_err(|e| LnUrlPayError::Generic { err: e.to_string() })?
@@ -3633,6 +3788,7 @@ impl LiquidSdk {
lnurl_withdraw_endpoint: None, lnurl_withdraw_endpoint: None,
}), }),
bip353_address: None, bip353_address: None,
asset_fees: None,
})?; })?;
// Get the payment with the lnurl_info details // Get the payment with the lnurl_info details
payment = self.persister.get_payment(&tx_id)?.unwrap_or(payment); payment = self.persister.get_payment(&tx_id)?.unwrap_or(payment);
@@ -3701,6 +3857,7 @@ impl LiquidSdk {
..Default::default() ..Default::default()
}), }),
bip353_address: None, bip353_address: None,
asset_fees: None,
})?; })?;
} }
} }

View File

@@ -320,6 +320,7 @@ impl SendSwapHandler {
description, description,
lnurl_info: Some(lnurl_info), lnurl_info: Some(lnurl_info),
bip353_address, bip353_address,
asset_fees: None,
})?; })?;
return Ok(true); return Ok(true);
} }

View File

@@ -305,6 +305,7 @@ pub(crate) struct PaymentDetailsSyncData {
pub(crate) description: Option<String>, pub(crate) description: Option<String>,
pub(crate) lnurl_info: Option<LnUrlInfo>, pub(crate) lnurl_info: Option<LnUrlInfo>,
pub(crate) bip353_address: Option<String>, pub(crate) bip353_address: Option<String>,
pub(crate) asset_fees: Option<u64>,
} }
impl PaymentDetailsSyncData { impl PaymentDetailsSyncData {
@@ -315,6 +316,7 @@ impl PaymentDetailsSyncData {
"description" => clone_if_set(&mut self.description, &other.description), "description" => clone_if_set(&mut self.description, &other.description),
"lnurl_info" => clone_if_set(&mut self.lnurl_info, &other.lnurl_info), "lnurl_info" => clone_if_set(&mut self.lnurl_info, &other.lnurl_info),
"bip353_address" => clone_if_set(&mut self.bip353_address, &other.bip353_address), "bip353_address" => clone_if_set(&mut self.bip353_address, &other.bip353_address),
"asset_fees" => self.asset_fees = other.asset_fees,
_ => continue, _ => continue,
} }
} }
@@ -329,6 +331,7 @@ impl From<PaymentTxDetails> for PaymentDetailsSyncData {
description: value.description, description: value.description,
lnurl_info: value.lnurl_info, lnurl_info: value.lnurl_info,
bip353_address: value.bip353_address, bip353_address: value.bip353_address,
asset_fees: value.asset_fees,
} }
} }
} }
@@ -341,6 +344,7 @@ impl From<PaymentDetailsSyncData> for PaymentTxDetails {
description: val.description, description: val.description,
lnurl_info: val.lnurl_info, lnurl_info: val.lnurl_info,
bip353_address: val.bip353_address, bip353_address: val.bip353_address,
asset_fees: val.asset_fees,
} }
} }
} }

View File

@@ -19,7 +19,7 @@ pub(crate) mod data;
const MESSAGE_PREFIX: &[u8; 13] = b"realtimesync:"; const MESSAGE_PREFIX: &[u8; 13] = b"realtimesync:";
lazy_static! { 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)] #[derive(Copy, Clone)]

View File

@@ -1,6 +1,6 @@
#![cfg(test)] #![cfg(test)]
use std::{collections::HashMap, str::FromStr}; use std::{collections::HashMap, str::FromStr, sync::Mutex};
use crate::{ use crate::{
error::PaymentError, error::PaymentError,
@@ -18,15 +18,16 @@ use lwk_wollet::{
self, self,
bip32::{DerivationPath, Xpriv, Xpub}, 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 _}, elements_miniscript::{slip77::MasterBlindingKey, ToPublicKey as _},
secp256k1::{All, Message}, secp256k1::{All, Message},
WalletTx, WalletTx, WalletTxOut,
}; };
use sdk_common::utils::Arc; use sdk_common::utils::Arc;
pub(crate) struct MockWallet { pub(crate) struct MockWallet {
signer: SdkLwkSigner, signer: SdkLwkSigner,
utxos: Mutex<Vec<WalletTxOut>>,
} }
lazy_static! { lazy_static! {
@@ -38,7 +39,15 @@ lazy_static! {
impl MockWallet { impl MockWallet {
pub(crate) fn new(user_signer: Arc<Box<dyn Signer>>) -> Result<Self> { pub(crate) fn new(user_signer: Arc<Box<dyn Signer>>) -> Result<Self> {
let signer = crate::signer::SdkLwkSigner::new(user_signer.clone())?; 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<WalletTxOut>) -> &Self {
*self.utxos.lock().unwrap() = utxos;
self
} }
} }
@@ -52,6 +61,10 @@ impl OnchainWallet for MockWallet {
Ok(Default::default()) Ok(Default::default())
} }
async fn asset_utxos(&self, _asset_id: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError> {
Ok(self.utxos.lock().unwrap().clone())
}
async fn build_tx( async fn build_tx(
&self, &self,
_fee_rate: Option<f32>, _fee_rate: Option<f32>,
@@ -81,10 +94,21 @@ impl OnchainWallet for MockWallet {
Ok(TEST_LIQUID_TX.clone()) Ok(TEST_LIQUID_TX.clone())
} }
async fn sign_pset(
&self,
_pset: PartiallySignedTransaction,
) -> Result<Transaction, PaymentError> {
Ok(TEST_LIQUID_TX.clone())
}
async fn next_unused_address(&self) -> Result<Address, PaymentError> { async fn next_unused_address(&self) -> Result<Address, PaymentError> {
Ok(TEST_P2TR_ADDR.clone()) Ok(TEST_P2TR_ADDR.clone())
} }
async fn next_unused_change_address(&self) -> Result<Address, PaymentError> {
Ok(TEST_P2TR_ADDR.clone())
}
async fn tip(&self) -> u32 { async fn tip(&self) -> u32 {
0 0
} }

View File

@@ -8,11 +8,12 @@ use log::{debug, info, warn};
use lwk_common::Signer as LwkSigner; use lwk_common::Signer as LwkSigner;
use lwk_common::{singlesig_desc, Singlesig}; use lwk_common::{singlesig_desc, Singlesig};
use lwk_wollet::asyncr::{EsploraClient, EsploraClientBuilder}; 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::secp256k1::Message;
use lwk_wollet::{ use lwk_wollet::{
elements::{hex::ToHex, Address, Transaction}, ElementsNetwork, FsPersister, NoPersist, WalletTx, WalletTxOut, Wollet, WolletDescriptor,
ElementsNetwork, FsPersister, NoPersist, WalletTx, Wollet, WolletDescriptor,
}; };
use maybe_sync::{MaybeSend, MaybeSync}; use maybe_sync::{MaybeSend, MaybeSync};
use sdk_common::bitcoin::hashes::{sha256, Hash}; 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 /// List all transactions in the wallet mapped by tx id
async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError>; async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError>;
/// List all utxos in the wallet for a given asset
async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError>;
/// Build a transaction to send funds to a recipient /// Build a transaction to send funds to a recipient
async fn build_tx( async fn build_tx(
&self, &self,
@@ -78,9 +82,18 @@ pub trait OnchainWallet: MaybeSend + MaybeSync {
amount_sat: u64, amount_sat: u64,
) -> Result<Transaction, PaymentError>; ) -> Result<Transaction, PaymentError>;
/// Sign a partially signed transaction
async fn sign_pset(
&self,
pset: PartiallySignedTransaction,
) -> Result<Transaction, PaymentError>;
/// Get the next unused address in the wallet /// Get the next unused address in the wallet
async fn next_unused_address(&self) -> Result<Address, PaymentError>; async fn next_unused_address(&self) -> Result<Address, PaymentError>;
/// Get the next unused change address in the wallet
async fn next_unused_change_address(&self) -> Result<Address, PaymentError>;
/// Get the current tip of the blockchain the wallet is aware of /// Get the current tip of the blockchain the wallet is aware of
async fn tip(&self) -> u32; async fn tip(&self) -> u32;
@@ -272,6 +285,18 @@ impl LiquidOnchainWallet {
.map_err(|e| anyhow!("Invalid descriptor: {e}"))?; .map_err(|e| anyhow!("Invalid descriptor: {e}"))?;
Ok(descriptor_str.parse()?) Ok(descriptor_str.parse()?)
} }
async fn get_txout(&self, wallet: &Wollet, outpoint: &OutPoint) -> Result<TxOut> {
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] #[sdk_macros::async_trait]
@@ -295,6 +320,17 @@ impl OnchainWallet for LiquidOnchainWallet {
Ok(tx_map) Ok(tx_map)
} }
async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, 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 /// Build a transaction to send funds to a recipient
async fn build_tx( async fn build_tx(
&self, &self,
@@ -401,6 +437,50 @@ impl OnchainWallet for LiquidOnchainWallet {
} }
} }
async fn sign_pset(
&self,
mut pset: PartiallySignedTransaction,
) -> Result<Transaction, PaymentError> {
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 /// Get the next unused address in the wallet
async fn next_unused_address(&self) -> Result<Address, PaymentError> { async fn next_unused_address(&self) -> Result<Address, PaymentError> {
let tip = self.tip().await; let tip = self.tip().await;
@@ -432,6 +512,13 @@ impl OnchainWallet for LiquidOnchainWallet {
Ok(address) Ok(address)
} }
/// Get the next unused change address in the wallet
async fn next_unused_change_address(&self) -> Result<Address, PaymentError> {
let address = self.wallet.lock().await.change(None)?.address().clone();
Ok(address)
}
/// Get the current tip of the blockchain the wallet is aware of /// Get the current tip of the blockchain the wallet is aware of
async fn tip(&self) -> u32 { async fn tip(&self) -> u32 {
self.wallet.lock().await.tip().height() self.wallet.lock().await.tip().height()

View File

@@ -314,6 +314,7 @@ pub struct Config {
pub use_default_external_input_parsers: bool, pub use_default_external_input_parsers: bool,
pub onchain_fee_rate_leeway_sat_per_vbyte: Option<u32>, pub onchain_fee_rate_leeway_sat_per_vbyte: Option<u32>,
pub asset_metadata: Option<Vec<AssetMetadata>>, pub asset_metadata: Option<Vec<AssetMetadata>>,
pub sideswap_api_key: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -443,12 +444,14 @@ pub enum SendDestination {
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::PrepareSendResponse)] #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::PrepareSendResponse)]
pub struct PrepareSendResponse { pub struct PrepareSendResponse {
pub destination: SendDestination, pub destination: SendDestination,
pub fees_sat: u64, pub fees_sat: Option<u64>,
pub estimated_asset_fees: Option<f64>,
} }
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::SendPaymentRequest)] #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::SendPaymentRequest)]
pub struct SendPaymentRequest { pub struct SendPaymentRequest {
pub prepare_response: PrepareSendResponse, pub prepare_response: PrepareSendResponse,
pub use_asset_fees: Option<bool>,
} }
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::SendPaymentResponse)] #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::SendPaymentResponse)]
@@ -464,6 +467,7 @@ pub enum PayAmount {
Asset { Asset {
asset_id: String, asset_id: String,
receiver_amount: f64, receiver_amount: f64,
estimate_asset_fees: Option<bool>,
}, },
Drain, Drain,
} }
@@ -655,6 +659,7 @@ pub struct AssetMetadata {
pub name: String, pub name: String,
pub ticker: String, pub ticker: String,
pub precision: u8, pub precision: u8,
pub fiat_id: Option<String>,
} }
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::AssetInfo)] #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::AssetInfo)]
@@ -662,6 +667,7 @@ pub struct AssetInfo {
pub name: String, pub name: String,
pub ticker: String, pub ticker: String,
pub amount: f64, pub amount: f64,
pub fees: Option<f64>,
} }
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::PaymentDetails)] #[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::PaymentDetails)]

View File

@@ -1476,11 +1476,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
AssetInfo dco_decode_asset_info(dynamic raw) { AssetInfo dco_decode_asset_info(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
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( return AssetInfo(
name: dco_decode_String(arr[0]), name: dco_decode_String(arr[0]),
ticker: dco_decode_String(arr[1]), ticker: dco_decode_String(arr[1]),
amount: dco_decode_f_64(arr[2]), 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) { AssetMetadata dco_decode_asset_metadata(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
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( return AssetMetadata(
assetId: dco_decode_String(arr[0]), assetId: dco_decode_String(arr[0]),
name: dco_decode_String(arr[1]), name: dco_decode_String(arr[1]),
ticker: dco_decode_String(arr[2]), ticker: dco_decode_String(arr[2]),
precision: dco_decode_u_8(arr[3]), 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) { Config dco_decode_config(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
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( return Config(
liquidExplorer: dco_decode_blockchain_explorer(arr[0]), liquidExplorer: dco_decode_blockchain_explorer(arr[0]),
bitcoinExplorer: dco_decode_blockchain_explorer(arr[1]), bitcoinExplorer: dco_decode_blockchain_explorer(arr[1]),
@@ -1943,6 +1945,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
useDefaultExternalInputParsers: dco_decode_bool(arr[10]), useDefaultExternalInputParsers: dco_decode_bool(arr[10]),
onchainFeeRateLeewaySatPerVbyte: dco_decode_opt_box_autoadd_u_32(arr[11]), onchainFeeRateLeewaySatPerVbyte: dco_decode_opt_box_autoadd_u_32(arr[11]),
assetMetadata: dco_decode_opt_list_asset_metadata(arr[12]), 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: case 0:
return PayAmount_Bitcoin(receiverAmountSat: dco_decode_u_64(raw[1])); return PayAmount_Bitcoin(receiverAmountSat: dco_decode_u_64(raw[1]));
case 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: case 2:
return PayAmount_Drain(); return PayAmount_Drain();
default: default:
@@ -3015,10 +3022,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
PrepareSendResponse dco_decode_prepare_send_response(dynamic raw) { PrepareSendResponse dco_decode_prepare_send_response(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
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( return PrepareSendResponse(
destination: dco_decode_send_destination(arr[0]), 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) { SendPaymentRequest dco_decode_send_payment_request(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
if (arr.length != 1) throw Exception('unexpected arr length: expect 1 but see ${arr.length}'); 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])); return SendPaymentRequest(
prepareResponse: dco_decode_prepare_send_response(arr[0]),
useAssetFees: dco_decode_opt_box_autoadd_bool(arr[1]),
);
} }
@protected @protected
@@ -3503,7 +3514,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_name = sse_decode_String(deserializer); var var_name = sse_decode_String(deserializer);
var var_ticker = sse_decode_String(deserializer); var var_ticker = sse_decode_String(deserializer);
var var_amount = sse_decode_f_64(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 @protected
@@ -3513,7 +3525,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_name = sse_decode_String(deserializer); var var_name = sse_decode_String(deserializer);
var var_ticker = sse_decode_String(deserializer); var var_ticker = sse_decode_String(deserializer);
var var_precision = sse_decode_u_8(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 @protected
@@ -3964,6 +3983,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_useDefaultExternalInputParsers = sse_decode_bool(deserializer); var var_useDefaultExternalInputParsers = sse_decode_bool(deserializer);
var var_onchainFeeRateLeewaySatPerVbyte = sse_decode_opt_box_autoadd_u_32(deserializer); var var_onchainFeeRateLeewaySatPerVbyte = sse_decode_opt_box_autoadd_u_32(deserializer);
var var_assetMetadata = sse_decode_opt_list_asset_metadata(deserializer); var var_assetMetadata = sse_decode_opt_list_asset_metadata(deserializer);
var var_sideswapApiKey = sse_decode_opt_String(deserializer);
return Config( return Config(
liquidExplorer: var_liquidExplorer, liquidExplorer: var_liquidExplorer,
bitcoinExplorer: var_bitcoinExplorer, bitcoinExplorer: var_bitcoinExplorer,
@@ -3978,6 +3998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
useDefaultExternalInputParsers: var_useDefaultExternalInputParsers, useDefaultExternalInputParsers: var_useDefaultExternalInputParsers,
onchainFeeRateLeewaySatPerVbyte: var_onchainFeeRateLeewaySatPerVbyte, onchainFeeRateLeewaySatPerVbyte: var_onchainFeeRateLeewaySatPerVbyte,
assetMetadata: var_assetMetadata, assetMetadata: var_assetMetadata,
sideswapApiKey: var_sideswapApiKey,
); );
} }
@@ -5044,7 +5065,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
case 1: case 1:
var var_assetId = sse_decode_String(deserializer); var var_assetId = sse_decode_String(deserializer);
var var_receiverAmount = sse_decode_f_64(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: case 2:
return PayAmount_Drain(); return PayAmount_Drain();
default: default:
@@ -5383,8 +5409,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
PrepareSendResponse sse_decode_prepare_send_response(SseDeserializer deserializer) { PrepareSendResponse sse_decode_prepare_send_response(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
var var_destination = sse_decode_send_destination(deserializer); var var_destination = sse_decode_send_destination(deserializer);
var var_feesSat = sse_decode_u_64(deserializer); var var_feesSat = sse_decode_opt_box_autoadd_u_64(deserializer);
return PrepareSendResponse(destination: var_destination, feesSat: var_feesSat); var var_estimatedAssetFees = sse_decode_opt_box_autoadd_f_64(deserializer);
return PrepareSendResponse(
destination: var_destination,
feesSat: var_feesSat,
estimatedAssetFees: var_estimatedAssetFees,
);
} }
@protected @protected
@@ -5613,7 +5644,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SendPaymentRequest sse_decode_send_payment_request(SseDeserializer deserializer) { SendPaymentRequest sse_decode_send_payment_request(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
var var_prepareResponse = sse_decode_prepare_send_response(deserializer); 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 @protected
@@ -5990,6 +6022,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_String(self.name, serializer); sse_encode_String(self.name, serializer);
sse_encode_String(self.ticker, serializer); sse_encode_String(self.ticker, serializer);
sse_encode_f_64(self.amount, serializer); sse_encode_f_64(self.amount, serializer);
sse_encode_opt_box_autoadd_f_64(self.fees, serializer);
} }
@protected @protected
@@ -5999,6 +6032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_String(self.name, serializer); sse_encode_String(self.name, serializer);
sse_encode_String(self.ticker, serializer); sse_encode_String(self.ticker, serializer);
sse_encode_u_8(self.precision, serializer); sse_encode_u_8(self.precision, serializer);
sse_encode_opt_String(self.fiatId, serializer);
} }
@protected @protected
@@ -6456,6 +6490,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_bool(self.useDefaultExternalInputParsers, serializer); sse_encode_bool(self.useDefaultExternalInputParsers, serializer);
sse_encode_opt_box_autoadd_u_32(self.onchainFeeRateLeewaySatPerVbyte, serializer); sse_encode_opt_box_autoadd_u_32(self.onchainFeeRateLeewaySatPerVbyte, serializer);
sse_encode_opt_list_asset_metadata(self.assetMetadata, serializer); sse_encode_opt_list_asset_metadata(self.assetMetadata, serializer);
sse_encode_opt_String(self.sideswapApiKey, serializer);
} }
@protected @protected
@@ -7309,10 +7344,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
case PayAmount_Bitcoin(receiverAmountSat: final receiverAmountSat): case PayAmount_Bitcoin(receiverAmountSat: final receiverAmountSat):
sse_encode_i_32(0, serializer); sse_encode_i_32(0, serializer);
sse_encode_u_64(receiverAmountSat, 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_i_32(1, serializer);
sse_encode_String(assetId, serializer); sse_encode_String(assetId, serializer);
sse_encode_f_64(receiverAmount, serializer); sse_encode_f_64(receiverAmount, serializer);
sse_encode_opt_box_autoadd_bool(estimateAssetFees, serializer);
case PayAmount_Drain(): case PayAmount_Drain():
sse_encode_i_32(2, serializer); 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) { void sse_encode_prepare_send_response(PrepareSendResponse self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_send_destination(self.destination, serializer); 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 @protected
@@ -7759,6 +7800,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
void sse_encode_send_payment_request(SendPaymentRequest self, SseSerializer serializer) { void sse_encode_send_payment_request(SendPaymentRequest self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_prepare_send_response(self.prepareResponse, serializer); sse_encode_prepare_send_response(self.prepareResponse, serializer);
sse_encode_opt_box_autoadd_bool(self.useAssetFees, serializer);
} }
@protected @protected

View File

@@ -2233,6 +2233,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
wireObj.name = cst_encode_String(apiObj.name); wireObj.name = cst_encode_String(apiObj.name);
wireObj.ticker = cst_encode_String(apiObj.ticker); wireObj.ticker = cst_encode_String(apiObj.ticker);
wireObj.amount = cst_encode_f_64(apiObj.amount); wireObj.amount = cst_encode_f_64(apiObj.amount);
wireObj.fees = cst_encode_opt_box_autoadd_f_64(apiObj.fees);
} }
@protected @protected
@@ -2241,6 +2242,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
wireObj.name = cst_encode_String(apiObj.name); wireObj.name = cst_encode_String(apiObj.name);
wireObj.ticker = cst_encode_String(apiObj.ticker); wireObj.ticker = cst_encode_String(apiObj.ticker);
wireObj.precision = cst_encode_u_8(apiObj.precision); wireObj.precision = cst_encode_u_8(apiObj.precision);
wireObj.fiat_id = cst_encode_opt_String(apiObj.fiatId);
} }
@protected @protected
@@ -2724,6 +2726,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
apiObj.onchainFeeRateLeewaySatPerVbyte, apiObj.onchainFeeRateLeewaySatPerVbyte,
); );
wireObj.asset_metadata = cst_encode_opt_list_asset_metadata(apiObj.assetMetadata); wireObj.asset_metadata = cst_encode_opt_list_asset_metadata(apiObj.assetMetadata);
wireObj.sideswap_api_key = cst_encode_opt_String(apiObj.sideswapApiKey);
} }
@protected @protected
@@ -3325,9 +3328,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
if (apiObj is PayAmount_Asset) { if (apiObj is PayAmount_Asset) {
var pre_asset_id = cst_encode_String(apiObj.assetId); var pre_asset_id = cst_encode_String(apiObj.assetId);
var pre_receiver_amount = cst_encode_f_64(apiObj.receiverAmount); 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.tag = 1;
wireObj.kind.Asset.asset_id = pre_asset_id; wireObj.kind.Asset.asset_id = pre_asset_id;
wireObj.kind.Asset.receiver_amount = pre_receiver_amount; wireObj.kind.Asset.receiver_amount = pre_receiver_amount;
wireObj.kind.Asset.estimate_asset_fees = pre_estimate_asset_fees;
return; return;
} }
if (apiObj is PayAmount_Drain) { if (apiObj is PayAmount_Drain) {
@@ -3662,7 +3667,8 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
wire_cst_prepare_send_response wireObj, wire_cst_prepare_send_response wireObj,
) { ) {
cst_api_fill_to_wire_send_destination(apiObj.destination, wireObj.destination); 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 @protected
@@ -3879,6 +3885,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
wire_cst_send_payment_request wireObj, wire_cst_send_payment_request wireObj,
) { ) {
cst_api_fill_to_wire_prepare_send_response(apiObj.prepareResponse, wireObj.prepare_response); 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 @protected
@@ -6703,6 +6710,8 @@ final class wire_cst_PayAmount_Asset extends ffi.Struct {
@ffi.Double() @ffi.Double()
external double receiver_amount; external double receiver_amount;
external ffi.Pointer<ffi.Bool> estimate_asset_fees;
} }
final class PayAmountKind extends ffi.Union { 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 { final class wire_cst_prepare_send_response extends ffi.Struct {
external wire_cst_send_destination destination; external wire_cst_send_destination destination;
@ffi.Uint64() external ffi.Pointer<ffi.Uint64> fees_sat;
external int fees_sat;
external ffi.Pointer<ffi.Double> estimated_asset_fees;
} }
final class wire_cst_send_payment_request extends ffi.Struct { final class wire_cst_send_payment_request extends ffi.Struct {
external wire_cst_prepare_send_response prepare_response; external wire_cst_prepare_send_response prepare_response;
external ffi.Pointer<ffi.Bool> use_asset_fees;
} }
final class wire_cst_sign_message_request extends ffi.Struct { 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() @ffi.Double()
external double amount; external double amount;
external ffi.Pointer<ffi.Double> fees;
} }
final class wire_cst_PaymentDetails_Liquid extends ffi.Struct { final class wire_cst_PaymentDetails_Liquid extends ffi.Struct {
@@ -7133,6 +7147,8 @@ final class wire_cst_asset_metadata extends ffi.Struct {
@ffi.Uint8() @ffi.Uint8()
external int precision; external int precision;
external ffi.Pointer<wire_cst_list_prim_u_8_strict> fiat_id;
} }
final class wire_cst_list_asset_metadata extends ffi.Struct { 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<ffi.Uint32> onchain_fee_rate_leeway_sat_per_vbyte; external ffi.Pointer<ffi.Uint32> onchain_fee_rate_leeway_sat_per_vbyte;
external ffi.Pointer<wire_cst_list_asset_metadata> asset_metadata; external ffi.Pointer<wire_cst_list_asset_metadata> asset_metadata;
external ffi.Pointer<wire_cst_list_prim_u_8_strict> sideswap_api_key;
} }
final class wire_cst_connect_request extends ffi.Struct { 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 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 DEFAULT_ZERO_CONF_MAX_SAT = 1000000;
const int CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS = 4320; const int CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS = 4320;

View File

@@ -65,10 +65,14 @@ class AssetInfo {
/// decimal shifted to the left by the [precision](AssetMetadata::precision) /// decimal shifted to the left by the [precision](AssetMetadata::precision)
final double amount; 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 @override
int get hashCode => name.hashCode ^ ticker.hashCode ^ amount.hashCode; int get hashCode => name.hashCode ^ ticker.hashCode ^ amount.hashCode ^ fees.hashCode;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -77,7 +81,8 @@ class AssetInfo {
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
name == other.name && name == other.name &&
ticker == other.ticker && 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 /// 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. /// For example, precision of 2 shifts the decimal 2 places left from the satoshi amount.
final int precision; final int precision;
/// The optional ID of the fiat currency used to represent the asset
final String? fiatId;
const AssetMetadata({ const AssetMetadata({
required this.assetId, required this.assetId,
required this.name, required this.name,
required this.ticker, required this.ticker,
required this.precision, required this.precision,
this.fiatId,
}); });
@override @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 @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -115,7 +125,8 @@ class AssetMetadata {
assetId == other.assetId && assetId == other.assetId &&
name == other.name && name == other.name &&
ticker == other.ticker && ticker == other.ticker &&
precision == other.precision; precision == other.precision &&
fiatId == other.fiatId;
} }
/// An argument when calling [crate::sdk::LiquidSdk::backup]. /// 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. /// By default the asset metadata for Liquid Bitcoin and Tether USD are included.
final List<AssetMetadata>? assetMetadata; final List<AssetMetadata>? assetMetadata;
/// The SideSwap API key used for making requests to the SideSwap payjoin service
final String? sideswapApiKey;
const Config({ const Config({
required this.liquidExplorer, required this.liquidExplorer,
required this.bitcoinExplorer, required this.bitcoinExplorer,
@@ -305,6 +319,7 @@ class Config {
required this.useDefaultExternalInputParsers, required this.useDefaultExternalInputParsers,
this.onchainFeeRateLeewaySatPerVbyte, this.onchainFeeRateLeewaySatPerVbyte,
this.assetMetadata, this.assetMetadata,
this.sideswapApiKey,
}); });
@override @override
@@ -321,7 +336,8 @@ class Config {
externalInputParsers.hashCode ^ externalInputParsers.hashCode ^
useDefaultExternalInputParsers.hashCode ^ useDefaultExternalInputParsers.hashCode ^
onchainFeeRateLeewaySatPerVbyte.hashCode ^ onchainFeeRateLeewaySatPerVbyte.hashCode ^
assetMetadata.hashCode; assetMetadata.hashCode ^
sideswapApiKey.hashCode;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -340,7 +356,8 @@ class Config {
externalInputParsers == other.externalInputParsers && externalInputParsers == other.externalInputParsers &&
useDefaultExternalInputParsers == other.useDefaultExternalInputParsers && useDefaultExternalInputParsers == other.useDefaultExternalInputParsers &&
onchainFeeRateLeewaySatPerVbyte == other.onchainFeeRateLeewaySatPerVbyte && onchainFeeRateLeewaySatPerVbyte == other.onchainFeeRateLeewaySatPerVbyte &&
assetMetadata == other.assetMetadata; assetMetadata == other.assetMetadata &&
sideswapApiKey == other.sideswapApiKey;
} }
/// An argument when calling [crate::sdk::LiquidSdk::connect]. /// 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; const factory PayAmount.bitcoin({required BigInt receiverAmountSat}) = PayAmount_Bitcoin;
/// The amount of an asset that will be received /// 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 /// Indicates that all available Bitcoin funds should be sent
const factory PayAmount.drain() = PayAmount_Drain; const factory PayAmount.drain() = PayAmount_Drain;
@@ -1365,12 +1386,20 @@ class PrepareSendRequest {
/// Returned when calling [crate::sdk::LiquidSdk::prepare_send_payment]. /// Returned when calling [crate::sdk::LiquidSdk::prepare_send_payment].
class PrepareSendResponse { class PrepareSendResponse {
final SendDestination destination; 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 @override
int get hashCode => destination.hashCode ^ feesSat.hashCode; int get hashCode => destination.hashCode ^ feesSat.hashCode ^ estimatedAssetFees.hashCode;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -1378,7 +1407,8 @@ class PrepareSendResponse {
other is PrepareSendResponse && other is PrepareSendResponse &&
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
destination == other.destination && destination == other.destination &&
feesSat == other.feesSat; feesSat == other.feesSat &&
estimatedAssetFees == other.estimatedAssetFees;
} }
@freezed @freezed
@@ -1616,18 +1646,20 @@ sealed class SendDestination with _$SendDestination {
/// An argument when calling [crate::sdk::LiquidSdk::send_payment]. /// An argument when calling [crate::sdk::LiquidSdk::send_payment].
class SendPaymentRequest { class SendPaymentRequest {
final PrepareSendResponse prepareResponse; final PrepareSendResponse prepareResponse;
final bool? useAssetFees;
const SendPaymentRequest({required this.prepareResponse}); const SendPaymentRequest({required this.prepareResponse, this.useAssetFees});
@override @override
int get hashCode => prepareResponse.hashCode; int get hashCode => prepareResponse.hashCode ^ useAssetFees.hashCode;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is SendPaymentRequest && other is SendPaymentRequest &&
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
prepareResponse == other.prepareResponse; prepareResponse == other.prepareResponse &&
useAssetFees == other.useAssetFees;
} }
/// Returned when calling [crate::sdk::LiquidSdk::send_payment]. /// Returned when calling [crate::sdk::LiquidSdk::send_payment].

View File

@@ -865,11 +865,12 @@ as BigInt,
class PayAmount_Asset extends PayAmount { 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 String assetId;
final double receiverAmount; final double receiverAmount;
final bool? estimateAssetFees;
/// Create a copy of PayAmount /// Create a copy of PayAmount
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -881,16 +882,16 @@ $PayAmount_AssetCopyWith<PayAmount_Asset> get copyWith => _$PayAmount_AssetCopyW
@override @override
bool operator ==(Object other) { 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 @override
int get hashCode => Object.hash(runtimeType,assetId,receiverAmount); int get hashCode => Object.hash(runtimeType,assetId,receiverAmount,estimateAssetFees);
@override @override
String toString() { 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; factory $PayAmount_AssetCopyWith(PayAmount_Asset value, $Res Function(PayAmount_Asset) _then) = _$PayAmount_AssetCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of PayAmount
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(PayAmount_Asset(
assetId: null == assetId ? _self.assetId : assetId // ignore: cast_nullable_to_non_nullable 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 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?,
)); ));
} }

View File

@@ -4603,6 +4603,8 @@ final class wire_cst_PayAmount_Asset extends ffi.Struct {
@ffi.Double() @ffi.Double()
external double receiver_amount; external double receiver_amount;
external ffi.Pointer<ffi.Bool> estimate_asset_fees;
} }
final class PayAmountKind extends ffi.Union { 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 { final class wire_cst_prepare_send_response extends ffi.Struct {
external wire_cst_send_destination destination; external wire_cst_send_destination destination;
@ffi.Uint64() external ffi.Pointer<ffi.Uint64> fees_sat;
external int fees_sat;
external ffi.Pointer<ffi.Double> estimated_asset_fees;
} }
final class wire_cst_send_payment_request extends ffi.Struct { final class wire_cst_send_payment_request extends ffi.Struct {
external wire_cst_prepare_send_response prepare_response; external wire_cst_prepare_send_response prepare_response;
external ffi.Pointer<ffi.Bool> use_asset_fees;
} }
final class wire_cst_sign_message_request extends ffi.Struct { 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() @ffi.Double()
external double amount; external double amount;
external ffi.Pointer<ffi.Double> fees;
} }
final class wire_cst_PaymentDetails_Liquid extends ffi.Struct { final class wire_cst_PaymentDetails_Liquid extends ffi.Struct {
@@ -5033,6 +5040,8 @@ final class wire_cst_asset_metadata extends ffi.Struct {
@ffi.Uint8() @ffi.Uint8()
external int precision; external int precision;
external ffi.Pointer<wire_cst_list_prim_u_8_strict> fiat_id;
} }
final class wire_cst_list_asset_metadata extends ffi.Struct { 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<ffi.Uint32> onchain_fee_rate_leeway_sat_per_vbyte; external ffi.Pointer<ffi.Uint32> onchain_fee_rate_leeway_sat_per_vbyte;
external ffi.Pointer<wire_cst_list_asset_metadata> asset_metadata; external ffi.Pointer<wire_cst_list_asset_metadata> asset_metadata;
external ffi.Pointer<wire_cst_list_prim_u_8_strict> sideswap_api_key;
} }
final class wire_cst_connect_request extends ffi.Struct { 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 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 DEFAULT_ZERO_CONF_MAX_SAT = 1000000;
const int CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS = 4320; const int CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS = 4320;

View File

@@ -156,7 +156,8 @@ fun asAssetInfo(assetInfo: ReadableMap): AssetInfo? {
val name = assetInfo.getString("name")!! val name = assetInfo.getString("name")!!
val ticker = assetInfo.getString("ticker")!! val ticker = assetInfo.getString("ticker")!!
val amount = assetInfo.getDouble("amount") 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 = fun readableMapOf(assetInfo: AssetInfo): ReadableMap =
@@ -164,6 +165,7 @@ fun readableMapOf(assetInfo: AssetInfo): ReadableMap =
"name" to assetInfo.name, "name" to assetInfo.name,
"ticker" to assetInfo.ticker, "ticker" to assetInfo.ticker,
"amount" to assetInfo.amount, "amount" to assetInfo.amount,
"fees" to assetInfo.fees,
) )
fun asAssetInfoList(arr: ReadableArray): List<AssetInfo> { fun asAssetInfoList(arr: ReadableArray): List<AssetInfo> {
@@ -194,7 +196,8 @@ fun asAssetMetadata(assetMetadata: ReadableMap): AssetMetadata? {
val name = assetMetadata.getString("name")!! val name = assetMetadata.getString("name")!!
val ticker = assetMetadata.getString("ticker")!! val ticker = assetMetadata.getString("ticker")!!
val precision = assetMetadata.getInt("precision").toUByte() 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 = fun readableMapOf(assetMetadata: AssetMetadata): ReadableMap =
@@ -203,6 +206,7 @@ fun readableMapOf(assetMetadata: AssetMetadata): ReadableMap =
"name" to assetMetadata.name, "name" to assetMetadata.name,
"ticker" to assetMetadata.ticker, "ticker" to assetMetadata.ticker,
"precision" to assetMetadata.precision, "precision" to assetMetadata.precision,
"fiatId" to assetMetadata.fiatId,
) )
fun asAssetMetadataList(arr: ReadableArray): List<AssetMetadata> { fun asAssetMetadataList(arr: ReadableArray): List<AssetMetadata> {
@@ -476,6 +480,7 @@ fun asConfig(config: ReadableMap): Config? {
} else { } else {
null null
} }
val sideswapApiKey = if (hasNonNullKey(config, "sideswapApiKey")) config.getString("sideswapApiKey") else null
return Config( return Config(
liquidExplorer, liquidExplorer,
bitcoinExplorer, bitcoinExplorer,
@@ -490,6 +495,7 @@ fun asConfig(config: ReadableMap): Config? {
externalInputParsers, externalInputParsers,
onchainFeeRateLeewaySatPerVbyte, onchainFeeRateLeewaySatPerVbyte,
assetMetadata, assetMetadata,
sideswapApiKey,
) )
} }
@@ -508,6 +514,7 @@ fun readableMapOf(config: Config): ReadableMap =
"externalInputParsers" to config.externalInputParsers?.let { readableArrayOf(it) }, "externalInputParsers" to config.externalInputParsers?.let { readableArrayOf(it) },
"onchainFeeRateLeewaySatPerVbyte" to config.onchainFeeRateLeewaySatPerVbyte, "onchainFeeRateLeewaySatPerVbyte" to config.onchainFeeRateLeewaySatPerVbyte,
"assetMetadata" to config.assetMetadata?.let { readableArrayOf(it) }, "assetMetadata" to config.assetMetadata?.let { readableArrayOf(it) },
"sideswapApiKey" to config.sideswapApiKey,
) )
fun asConfigList(arr: ReadableArray): List<Config> { fun asConfigList(arr: ReadableArray): List<Config> {
@@ -2281,21 +2288,31 @@ fun asPrepareSendResponse(prepareSendResponse: ReadableMap): PrepareSendResponse
prepareSendResponse, prepareSendResponse,
arrayOf( arrayOf(
"destination", "destination",
"feesSat",
), ),
) )
) { ) {
return null return null
} }
val destination = prepareSendResponse.getMap("destination")?.let { asSendDestination(it) }!! val destination = prepareSendResponse.getMap("destination")?.let { asSendDestination(it) }!!
val feesSat = prepareSendResponse.getDouble("feesSat").toULong() val feesSat = if (hasNonNullKey(prepareSendResponse, "feesSat")) prepareSendResponse.getDouble("feesSat").toULong() else null
return PrepareSendResponse(destination, feesSat) val estimatedAssetFees =
if (hasNonNullKey(
prepareSendResponse,
"estimatedAssetFees",
)
) {
prepareSendResponse.getDouble("estimatedAssetFees")
} else {
null
}
return PrepareSendResponse(destination, feesSat, estimatedAssetFees)
} }
fun readableMapOf(prepareSendResponse: PrepareSendResponse): ReadableMap = fun readableMapOf(prepareSendResponse: PrepareSendResponse): ReadableMap =
readableMapOf( readableMapOf(
"destination" to readableMapOf(prepareSendResponse.destination), "destination" to readableMapOf(prepareSendResponse.destination),
"feesSat" to prepareSendResponse.feesSat, "feesSat" to prepareSendResponse.feesSat,
"estimatedAssetFees" to prepareSendResponse.estimatedAssetFees,
) )
fun asPrepareSendResponseList(arr: ReadableArray): List<PrepareSendResponse> { fun asPrepareSendResponseList(arr: ReadableArray): List<PrepareSendResponse> {
@@ -2684,12 +2701,14 @@ fun asSendPaymentRequest(sendPaymentRequest: ReadableMap): SendPaymentRequest? {
return null return null
} }
val prepareResponse = sendPaymentRequest.getMap("prepareResponse")?.let { asPrepareSendResponse(it) }!! 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 = fun readableMapOf(sendPaymentRequest: SendPaymentRequest): ReadableMap =
readableMapOf( readableMapOf(
"prepareResponse" to readableMapOf(sendPaymentRequest.prepareResponse), "prepareResponse" to readableMapOf(sendPaymentRequest.prepareResponse),
"useAssetFees" to sendPaymentRequest.useAssetFees,
) )
fun asSendPaymentRequestList(arr: ReadableArray): List<SendPaymentRequest> { fun asSendPaymentRequestList(arr: ReadableArray): List<SendPaymentRequest> {
@@ -3405,7 +3424,8 @@ fun asPayAmount(payAmount: ReadableMap): PayAmount? {
if (type == "asset") { if (type == "asset") {
val assetId = payAmount.getString("assetId")!! val assetId = payAmount.getString("assetId")!!
val receiverAmount = payAmount.getDouble("receiverAmount") 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") { if (type == "drain") {
return PayAmount.Drain return PayAmount.Drain
@@ -3424,6 +3444,7 @@ fun readableMapOf(payAmount: PayAmount): ReadableMap? {
pushToMap(map, "type", "asset") pushToMap(map, "type", "asset")
pushToMap(map, "assetId", payAmount.assetId) pushToMap(map, "assetId", payAmount.assetId)
pushToMap(map, "receiverAmount", payAmount.receiverAmount) pushToMap(map, "receiverAmount", payAmount.receiverAmount)
pushToMap(map, "estimateAssetFees", payAmount.estimateAssetFees)
} }
is PayAmount.Drain -> { is PayAmount.Drain -> {
pushToMap(map, "type", "drain") pushToMap(map, "type", "drain")

View File

@@ -177,8 +177,15 @@ enum BreezSDKLiquidMapper {
guard let amount = assetInfo["amount"] as? Double else { guard let amount = assetInfo["amount"] as? Double else {
throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "amount", typeName: "AssetInfo")) 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?] { static func dictionaryOf(assetInfo: AssetInfo) -> [String: Any?] {
@@ -186,6 +193,7 @@ enum BreezSDKLiquidMapper {
"name": assetInfo.name, "name": assetInfo.name,
"ticker": assetInfo.ticker, "ticker": assetInfo.ticker,
"amount": assetInfo.amount, "amount": assetInfo.amount,
"fees": assetInfo.fees == nil ? nil : assetInfo.fees,
] ]
} }
@@ -219,8 +227,15 @@ enum BreezSDKLiquidMapper {
guard let precision = assetMetadata["precision"] as? UInt8 else { guard let precision = assetMetadata["precision"] as? UInt8 else {
throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "precision", typeName: "AssetMetadata")) 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?] { static func dictionaryOf(assetMetadata: AssetMetadata) -> [String: Any?] {
@@ -229,6 +244,7 @@ enum BreezSDKLiquidMapper {
"name": assetMetadata.name, "name": assetMetadata.name,
"ticker": assetMetadata.ticker, "ticker": assetMetadata.ticker,
"precision": assetMetadata.precision, "precision": assetMetadata.precision,
"fiatId": assetMetadata.fiatId == nil ? nil : assetMetadata.fiatId,
] ]
} }
@@ -561,7 +577,15 @@ enum BreezSDKLiquidMapper {
assetMetadata = try asAssetMetadataList(arr: assetMetadataTmp) 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?] { static func dictionaryOf(config: Config) -> [String: Any?] {
@@ -579,6 +603,7 @@ enum BreezSDKLiquidMapper {
"externalInputParsers": config.externalInputParsers == nil ? nil : arrayOf(externalInputParserList: config.externalInputParsers!), "externalInputParsers": config.externalInputParsers == nil ? nil : arrayOf(externalInputParserList: config.externalInputParsers!),
"onchainFeeRateLeewaySatPerVbyte": config.onchainFeeRateLeewaySatPerVbyte == nil ? nil : config.onchainFeeRateLeewaySatPerVbyte, "onchainFeeRateLeewaySatPerVbyte": config.onchainFeeRateLeewaySatPerVbyte == nil ? nil : config.onchainFeeRateLeewaySatPerVbyte,
"assetMetadata": config.assetMetadata == nil ? nil : arrayOf(assetMetadataList: config.assetMetadata!), "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) let destination = try asSendDestination(sendDestination: destinationTmp)
guard let feesSat = prepareSendResponse["feesSat"] as? UInt64 else { var feesSat: UInt64?
throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "feesSat", typeName: "PrepareSendResponse")) 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?] { static func dictionaryOf(prepareSendResponse: PrepareSendResponse) -> [String: Any?] {
return [ return [
"destination": dictionaryOf(sendDestination: prepareSendResponse.destination), "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) 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?] { static func dictionaryOf(sendPaymentRequest: SendPaymentRequest) -> [String: Any?] {
return [ return [
"prepareResponse": dictionaryOf(prepareSendResponse: sendPaymentRequest.prepareResponse), "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 { guard let _receiverAmount = payAmount["receiverAmount"] as? Double else {
throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "receiverAmount", typeName: "PayAmount")) 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" { if type == "drain" {
return PayAmount.drain return PayAmount.drain
@@ -4208,12 +4256,13 @@ enum BreezSDKLiquidMapper {
] ]
case let .asset( case let .asset(
assetId, receiverAmount assetId, receiverAmount, estimateAssetFees
): ):
return [ return [
"type": "asset", "type": "asset",
"assetId": assetId, "assetId": assetId,
"receiverAmount": receiverAmount, "receiverAmount": receiverAmount,
"estimateAssetFees": estimateAssetFees == nil ? nil : estimateAssetFees,
] ]
case .drain: case .drain:

View File

@@ -46,6 +46,7 @@ export interface AssetInfo {
name: string name: string
ticker: string ticker: string
amount: number amount: number
fees?: number
} }
export interface AssetMetadata { export interface AssetMetadata {
@@ -53,6 +54,7 @@ export interface AssetMetadata {
name: string name: string
ticker: string ticker: string
precision: number precision: number
fiatId?: string
} }
export interface BackupRequest { export interface BackupRequest {
@@ -101,6 +103,7 @@ export interface Config {
externalInputParsers?: ExternalInputParser[] externalInputParsers?: ExternalInputParser[]
onchainFeeRateLeewaySatPerVbyte?: number onchainFeeRateLeewaySatPerVbyte?: number
assetMetadata?: AssetMetadata[] assetMetadata?: AssetMetadata[]
sideswapApiKey?: string
} }
export interface ConnectRequest { export interface ConnectRequest {
@@ -392,7 +395,8 @@ export interface PrepareSendRequest {
export interface PrepareSendResponse { export interface PrepareSendResponse {
destination: SendDestination destination: SendDestination
feesSat: number feesSat?: number
estimatedAssetFees?: number
} }
export interface Rate { export interface Rate {
@@ -455,6 +459,7 @@ export interface RouteHintHop {
export interface SendPaymentRequest { export interface SendPaymentRequest {
prepareResponse: PrepareSendResponse prepareResponse: PrepareSendResponse
useAssetFees?: boolean
} }
export interface SendPaymentResponse { export interface SendPaymentResponse {
@@ -682,6 +687,7 @@ export type PayAmount = {
type: PayAmountVariant.ASSET, type: PayAmountVariant.ASSET,
assetId: string assetId: string
receiverAmount: number receiverAmount: number
estimateAssetFees?: boolean
} | { } | {
type: PayAmountVariant.DRAIN type: PayAmountVariant.DRAIN
} }