mirror of
https://github.com/aljazceru/breez-sdk-liquid.git
synced 2025-12-24 09:24:25 +01:00
Pay fees with USDT asset (payjoin) (#779)
* Add payjoin implementation * Fix Core Wasm tests
This commit is contained in:
@@ -43,6 +43,10 @@ pub(crate) enum Command {
|
||||
#[clap(long = "asset")]
|
||||
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
|
||||
/// provided in the BIP21 URI.
|
||||
/// The asset id must also be provided.
|
||||
@@ -384,6 +388,7 @@ pub(crate) async fn handle_command(
|
||||
amount,
|
||||
amount_sat,
|
||||
asset_id,
|
||||
use_asset_fees,
|
||||
drain,
|
||||
delay,
|
||||
} => {
|
||||
@@ -409,6 +414,7 @@ pub(crate) async fn handle_command(
|
||||
(Some(asset_id), Some(receiver_amount), _, _) => Some(PayAmount::Asset {
|
||||
asset_id,
|
||||
receiver_amount,
|
||||
estimate_asset_fees: use_asset_fees,
|
||||
}),
|
||||
(None, None, Some(receiver_amount_sat), _) => Some(PayAmount::Bitcoin {
|
||||
receiver_amount_sat,
|
||||
@@ -424,16 +430,30 @@ pub(crate) async fn handle_command(
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_confirmation!(
|
||||
format!(
|
||||
"Fees: {} sat. Are the fees acceptable? (y/N) ",
|
||||
prepare_response.fees_sat
|
||||
),
|
||||
"Payment send halted"
|
||||
);
|
||||
let confirmation_msg = match (
|
||||
use_asset_fees.unwrap_or(false),
|
||||
prepare_response.fees_sat,
|
||||
prepare_response.estimated_asset_fees,
|
||||
) {
|
||||
(true, _, Some(asset_fees)) => {
|
||||
format!("Fees: approx {asset_fees}. Are the fees acceptable? (y/N) ")
|
||||
}
|
||||
(false, Some(fees_sat), _) => {
|
||||
format!("Fees: {fees_sat} sat. Are the fees acceptable? (y/N) ")
|
||||
}
|
||||
(true, _, None) => {
|
||||
bail!("Not able to pay asset fees")
|
||||
}
|
||||
(false, None, _) => {
|
||||
bail!("Not able to pay satoshi fees")
|
||||
}
|
||||
};
|
||||
|
||||
wait_confirmation!(confirmation_msg, "Payment send halted");
|
||||
|
||||
let send_payment_req = SendPaymentRequest {
|
||||
prepare_response: prepare_response.clone(),
|
||||
use_asset_fees,
|
||||
};
|
||||
|
||||
if let Some(delay) = delay {
|
||||
|
||||
@@ -22,6 +22,16 @@ typedef struct _Dart_Handle* Dart_Handle;
|
||||
|
||||
#define LIQUID_FEE_RATE_MSAT_PER_VBYTE (float)(LIQUID_FEE_RATE_SAT_PER_VBYTE * 1000.0)
|
||||
|
||||
#define MIN_FEE_RATE 0.1
|
||||
|
||||
#define WEIGHT_FIXED 222
|
||||
|
||||
#define WEIGHT_VIN_SINGLE_SIG_NATIVE 275
|
||||
|
||||
#define WEIGHT_VIN_SINGLE_SIG_NESTED 367
|
||||
|
||||
#define WEIGHT_VOUT_NESTED 270
|
||||
|
||||
/**
|
||||
* The maximum acceptable amount in satoshi when claiming using zero-conf
|
||||
*/
|
||||
@@ -358,6 +368,7 @@ typedef struct wire_cst_PayAmount_Bitcoin {
|
||||
typedef struct wire_cst_PayAmount_Asset {
|
||||
struct wire_cst_list_prim_u_8_strict *asset_id;
|
||||
double receiver_amount;
|
||||
bool *estimate_asset_fees;
|
||||
} wire_cst_PayAmount_Asset;
|
||||
|
||||
typedef union PayAmountKind {
|
||||
@@ -445,11 +456,13 @@ typedef struct wire_cst_restore_request {
|
||||
|
||||
typedef struct wire_cst_prepare_send_response {
|
||||
struct wire_cst_send_destination destination;
|
||||
uint64_t fees_sat;
|
||||
uint64_t *fees_sat;
|
||||
double *estimated_asset_fees;
|
||||
} wire_cst_prepare_send_response;
|
||||
|
||||
typedef struct wire_cst_send_payment_request {
|
||||
struct wire_cst_prepare_send_response prepare_response;
|
||||
bool *use_asset_fees;
|
||||
} wire_cst_send_payment_request;
|
||||
|
||||
typedef struct wire_cst_sign_message_request {
|
||||
@@ -536,6 +549,7 @@ typedef struct wire_cst_asset_info {
|
||||
struct wire_cst_list_prim_u_8_strict *name;
|
||||
struct wire_cst_list_prim_u_8_strict *ticker;
|
||||
double amount;
|
||||
double *fees;
|
||||
} wire_cst_asset_info;
|
||||
|
||||
typedef struct wire_cst_PaymentDetails_Liquid {
|
||||
@@ -670,6 +684,7 @@ typedef struct wire_cst_asset_metadata {
|
||||
struct wire_cst_list_prim_u_8_strict *name;
|
||||
struct wire_cst_list_prim_u_8_strict *ticker;
|
||||
uint8_t precision;
|
||||
struct wire_cst_list_prim_u_8_strict *fiat_id;
|
||||
} wire_cst_asset_metadata;
|
||||
|
||||
typedef struct wire_cst_list_asset_metadata {
|
||||
@@ -691,6 +706,7 @@ typedef struct wire_cst_config {
|
||||
bool use_default_external_input_parsers;
|
||||
uint32_t *onchain_fee_rate_leeway_sat_per_vbyte;
|
||||
struct wire_cst_list_asset_metadata *asset_metadata;
|
||||
struct wire_cst_list_prim_u_8_strict *sideswap_api_key;
|
||||
} wire_cst_config;
|
||||
|
||||
typedef struct wire_cst_connect_request {
|
||||
|
||||
@@ -345,6 +345,7 @@ dictionary Config {
|
||||
sequence<ExternalInputParser>? external_input_parsers = null;
|
||||
u32? onchain_fee_rate_leeway_sat_per_vbyte = null;
|
||||
sequence<AssetMetadata>? asset_metadata = null;
|
||||
string? sideswap_api_key = null;
|
||||
};
|
||||
|
||||
enum LiquidNetwork {
|
||||
@@ -443,11 +444,13 @@ interface SendDestination {
|
||||
|
||||
dictionary PrepareSendResponse {
|
||||
SendDestination destination;
|
||||
u64 fees_sat;
|
||||
u64? fees_sat;
|
||||
f64? estimated_asset_fees;
|
||||
};
|
||||
|
||||
dictionary SendPaymentRequest {
|
||||
PrepareSendResponse prepare_response;
|
||||
boolean? use_asset_fees = null;
|
||||
};
|
||||
|
||||
dictionary SendPaymentResponse {
|
||||
@@ -509,7 +512,7 @@ dictionary OnchainPaymentLimitsResponse {
|
||||
[Enum]
|
||||
interface PayAmount {
|
||||
Bitcoin(u64 receiver_amount_sat);
|
||||
Asset(string asset_id, f64 receiver_amount);
|
||||
Asset(string asset_id, f64 receiver_amount, boolean? estimate_asset_fees);
|
||||
Drain();
|
||||
};
|
||||
|
||||
@@ -609,6 +612,7 @@ dictionary AssetInfo {
|
||||
string name;
|
||||
string ticker;
|
||||
f64 amount;
|
||||
f64? fees;
|
||||
};
|
||||
|
||||
[Enum]
|
||||
@@ -722,6 +726,7 @@ dictionary AssetMetadata {
|
||||
string name;
|
||||
string ticker;
|
||||
u8 precision;
|
||||
string? fiat_id = null;
|
||||
};
|
||||
|
||||
namespace breez_sdk_liquid {
|
||||
|
||||
@@ -2,6 +2,8 @@ use anyhow::Error;
|
||||
use lwk_wollet::secp256k1;
|
||||
use sdk_common::prelude::{LnUrlAuthError, LnUrlPayError, LnUrlWithdrawError};
|
||||
|
||||
use crate::payjoin::error::PayjoinError;
|
||||
|
||||
pub type SdkResult<T, E = SdkError> = Result<T, E>;
|
||||
|
||||
#[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 {
|
||||
fn from(_: rusqlite::Error) -> Self {
|
||||
Self::PersistError
|
||||
|
||||
@@ -2413,10 +2413,12 @@ impl SseDecode for crate::model::AssetInfo {
|
||||
let mut var_name = <String>::sse_decode(deserializer);
|
||||
let mut var_ticker = <String>::sse_decode(deserializer);
|
||||
let mut var_amount = <f64>::sse_decode(deserializer);
|
||||
let mut var_fees = <Option<f64>>::sse_decode(deserializer);
|
||||
return crate::model::AssetInfo {
|
||||
name: var_name,
|
||||
ticker: var_ticker,
|
||||
amount: var_amount,
|
||||
fees: var_fees,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2428,11 +2430,13 @@ impl SseDecode for crate::model::AssetMetadata {
|
||||
let mut var_name = <String>::sse_decode(deserializer);
|
||||
let mut var_ticker = <String>::sse_decode(deserializer);
|
||||
let mut var_precision = <u8>::sse_decode(deserializer);
|
||||
let mut var_fiatId = <Option<String>>::sse_decode(deserializer);
|
||||
return crate::model::AssetMetadata {
|
||||
asset_id: var_assetId,
|
||||
name: var_name,
|
||||
ticker: var_ticker,
|
||||
precision: var_precision,
|
||||
fiat_id: var_fiatId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2585,6 +2589,7 @@ impl SseDecode for crate::model::Config {
|
||||
let mut var_onchainFeeRateLeewaySatPerVbyte = <Option<u32>>::sse_decode(deserializer);
|
||||
let mut var_assetMetadata =
|
||||
<Option<Vec<crate::model::AssetMetadata>>>::sse_decode(deserializer);
|
||||
let mut var_sideswapApiKey = <Option<String>>::sse_decode(deserializer);
|
||||
return crate::model::Config {
|
||||
liquid_explorer: var_liquidExplorer,
|
||||
bitcoin_explorer: var_bitcoinExplorer,
|
||||
@@ -2599,6 +2604,7 @@ impl SseDecode for crate::model::Config {
|
||||
use_default_external_input_parsers: var_useDefaultExternalInputParsers,
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: var_onchainFeeRateLeewaySatPerVbyte,
|
||||
asset_metadata: var_assetMetadata,
|
||||
sideswap_api_key: var_sideswapApiKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3885,9 +3891,11 @@ impl SseDecode for crate::model::PayAmount {
|
||||
1 => {
|
||||
let mut var_assetId = <String>::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 {
|
||||
asset_id: var_assetId,
|
||||
receiver_amount: var_receiverAmount,
|
||||
estimate_asset_fees: var_estimateAssetFees,
|
||||
};
|
||||
}
|
||||
2 => {
|
||||
@@ -4316,10 +4324,12 @@ impl SseDecode for crate::model::PrepareSendResponse {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut var_destination = <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 {
|
||||
destination: var_destination,
|
||||
fees_sat: var_feesSat,
|
||||
estimated_asset_fees: var_estimatedAssetFees,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4623,8 +4633,10 @@ impl SseDecode for crate::model::SendPaymentRequest {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut var_prepareResponse = <crate::model::PrepareSendResponse>::sse_decode(deserializer);
|
||||
let mut var_useAssetFees = <Option<bool>>::sse_decode(deserializer);
|
||||
return crate::model::SendPaymentRequest {
|
||||
prepare_response: var_prepareResponse,
|
||||
use_asset_fees: var_useAssetFees,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4991,6 +5003,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::AssetInfo {
|
||||
self.name.into_into_dart().into_dart(),
|
||||
self.ticker.into_into_dart().into_dart(),
|
||||
self.amount.into_into_dart().into_dart(),
|
||||
self.fees.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart()
|
||||
}
|
||||
@@ -5009,6 +5022,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::AssetMetadata {
|
||||
self.name.into_into_dart().into_dart(),
|
||||
self.ticker.into_into_dart().into_dart(),
|
||||
self.precision.into_into_dart().into_dart(),
|
||||
self.fiat_id.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart()
|
||||
}
|
||||
@@ -5228,6 +5242,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::Config {
|
||||
.into_into_dart()
|
||||
.into_dart(),
|
||||
self.asset_metadata.into_into_dart().into_dart(),
|
||||
self.sideswap_api_key.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart()
|
||||
}
|
||||
@@ -6246,10 +6261,12 @@ impl flutter_rust_bridge::IntoDart for crate::model::PayAmount {
|
||||
crate::model::PayAmount::Asset {
|
||||
asset_id,
|
||||
receiver_amount,
|
||||
estimate_asset_fees,
|
||||
} => [
|
||||
1.into_dart(),
|
||||
asset_id.into_into_dart().into_dart(),
|
||||
receiver_amount.into_into_dart().into_dart(),
|
||||
estimate_asset_fees.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
crate::model::PayAmount::Drain => [2.into_dart()].into_dart(),
|
||||
@@ -6767,6 +6784,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::PrepareSendResponse {
|
||||
[
|
||||
self.destination.into_into_dart().into_dart(),
|
||||
self.fees_sat.into_into_dart().into_dart(),
|
||||
self.estimated_asset_fees.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart()
|
||||
}
|
||||
@@ -7128,7 +7146,11 @@ impl flutter_rust_bridge::IntoIntoDart<crate::model::SendDestination>
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::model::SendPaymentRequest {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
[self.prepare_response.into_into_dart().into_dart()].into_dart()
|
||||
[
|
||||
self.prepare_response.into_into_dart().into_dart(),
|
||||
self.use_asset_fees.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart()
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||
@@ -7452,6 +7474,7 @@ impl SseEncode for crate::model::AssetInfo {
|
||||
<String>::sse_encode(self.name, serializer);
|
||||
<String>::sse_encode(self.ticker, 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.ticker, 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);
|
||||
<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<String>>::sse_encode(self.sideswap_api_key, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8605,10 +8630,12 @@ impl SseEncode for crate::model::PayAmount {
|
||||
crate::model::PayAmount::Asset {
|
||||
asset_id,
|
||||
receiver_amount,
|
||||
estimate_asset_fees,
|
||||
} => {
|
||||
<i32>::sse_encode(1, serializer);
|
||||
<String>::sse_encode(asset_id, serializer);
|
||||
<f64>::sse_encode(receiver_amount, serializer);
|
||||
<Option<bool>>::sse_encode(estimate_asset_fees, serializer);
|
||||
}
|
||||
crate::model::PayAmount::Drain => {
|
||||
<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
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<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
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<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(),
|
||||
ticker: self.ticker.cst_decode(),
|
||||
amount: self.amount.cst_decode(),
|
||||
fees: self.fees.cst_decode(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9536,6 +9566,7 @@ mod io {
|
||||
name: self.name.cst_decode(),
|
||||
ticker: self.ticker.cst_decode(),
|
||||
precision: self.precision.cst_decode(),
|
||||
fiat_id: self.fiat_id.cst_decode(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10044,6 +10075,7 @@ mod io {
|
||||
.onchain_fee_rate_leeway_sat_per_vbyte
|
||||
.cst_decode(),
|
||||
asset_metadata: self.asset_metadata.cst_decode(),
|
||||
sideswap_api_key: self.sideswap_api_key.cst_decode(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10878,6 +10910,7 @@ mod io {
|
||||
crate::model::PayAmount::Asset {
|
||||
asset_id: ans.asset_id.cst_decode(),
|
||||
receiver_amount: ans.receiver_amount.cst_decode(),
|
||||
estimate_asset_fees: ans.estimate_asset_fees.cst_decode(),
|
||||
}
|
||||
}
|
||||
2 => crate::model::PayAmount::Drain,
|
||||
@@ -11166,6 +11199,7 @@ mod io {
|
||||
crate::model::PrepareSendResponse {
|
||||
destination: self.destination.cst_decode(),
|
||||
fees_sat: self.fees_sat.cst_decode(),
|
||||
estimated_asset_fees: self.estimated_asset_fees.cst_decode(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11408,6 +11442,7 @@ mod io {
|
||||
fn cst_decode(self) -> crate::model::SendPaymentRequest {
|
||||
crate::model::SendPaymentRequest {
|
||||
prepare_response: self.prepare_response.cst_decode(),
|
||||
use_asset_fees: self.use_asset_fees.cst_decode(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11608,6 +11643,7 @@ mod io {
|
||||
name: core::ptr::null_mut(),
|
||||
ticker: core::ptr::null_mut(),
|
||||
amount: Default::default(),
|
||||
fees: core::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11623,6 +11659,7 @@ mod io {
|
||||
name: core::ptr::null_mut(),
|
||||
ticker: core::ptr::null_mut(),
|
||||
precision: Default::default(),
|
||||
fiat_id: core::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11752,6 +11789,7 @@ mod io {
|
||||
use_default_external_input_parsers: Default::default(),
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: core::ptr::null_mut(),
|
||||
asset_metadata: core::ptr::null_mut(),
|
||||
sideswap_api_key: core::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12539,7 +12577,8 @@ mod io {
|
||||
fn new_with_null_ptr() -> Self {
|
||||
Self {
|
||||
destination: Default::default(),
|
||||
fees_sat: Default::default(),
|
||||
fees_sat: core::ptr::null_mut(),
|
||||
estimated_asset_fees: core::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12742,6 +12781,7 @@ mod io {
|
||||
fn new_with_null_ptr() -> Self {
|
||||
Self {
|
||||
prepare_response: Default::default(),
|
||||
use_asset_fees: core::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13937,6 +13977,7 @@ mod io {
|
||||
name: *mut wire_cst_list_prim_u_8_strict,
|
||||
ticker: *mut wire_cst_list_prim_u_8_strict,
|
||||
amount: f64,
|
||||
fees: *mut f64,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -13945,6 +13986,7 @@ mod io {
|
||||
name: *mut wire_cst_list_prim_u_8_strict,
|
||||
ticker: *mut wire_cst_list_prim_u_8_strict,
|
||||
precision: u8,
|
||||
fiat_id: *mut wire_cst_list_prim_u_8_strict,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -14029,6 +14071,7 @@ mod io {
|
||||
use_default_external_input_parsers: bool,
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: *mut u32,
|
||||
asset_metadata: *mut wire_cst_list_asset_metadata,
|
||||
sideswap_api_key: *mut wire_cst_list_prim_u_8_strict,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -14722,6 +14765,7 @@ mod io {
|
||||
pub struct wire_cst_PayAmount_Asset {
|
||||
asset_id: *mut wire_cst_list_prim_u_8_strict,
|
||||
receiver_amount: f64,
|
||||
estimate_asset_fees: *mut bool,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -14952,7 +14996,8 @@ mod io {
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct wire_cst_prepare_send_response {
|
||||
destination: wire_cst_send_destination,
|
||||
fees_sat: u64,
|
||||
fees_sat: *mut u64,
|
||||
estimated_asset_fees: *mut f64,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -15171,6 +15216,7 @@ mod io {
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct wire_cst_send_payment_request {
|
||||
prepare_response: wire_cst_prepare_send_response,
|
||||
use_asset_fees: *mut bool,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
|
||||
@@ -177,6 +177,7 @@ pub(crate) mod lnurl;
|
||||
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
|
||||
pub mod logger;
|
||||
pub mod model;
|
||||
pub(crate) mod payjoin;
|
||||
pub mod persist;
|
||||
pub mod receive_swap;
|
||||
pub(crate) mod recover;
|
||||
|
||||
@@ -41,6 +41,8 @@ pub const LIQUID_FEE_RATE_SAT_PER_VBYTE: f64 = 0.1;
|
||||
pub const LIQUID_FEE_RATE_MSAT_PER_VBYTE: f32 = (LIQUID_FEE_RATE_SAT_PER_VBYTE * 1000.0) as f32;
|
||||
pub const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology";
|
||||
|
||||
const SIDESWAP_API_KEY: &str = "97fb6a1dfa37ee6656af92ef79675cc03b8ac4c52e04655f41edbd5af888dcc2";
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum BlockchainExplorer {
|
||||
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
|
||||
@@ -94,6 +96,8 @@ pub struct Config {
|
||||
/// See [AssetMetadata] for more details on how define asset metadata.
|
||||
/// By default the asset metadata for Liquid Bitcoin and Tether USD are included.
|
||||
pub asset_metadata: Option<Vec<AssetMetadata>>,
|
||||
/// The SideSwap API key used for making requests to the SideSwap payjoin service
|
||||
pub sideswap_api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -117,6 +121,7 @@ impl Config {
|
||||
use_default_external_input_parsers: true,
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: None,
|
||||
asset_metadata: None,
|
||||
sideswap_api_key: Some(SIDESWAP_API_KEY.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +146,7 @@ impl Config {
|
||||
use_default_external_input_parsers: true,
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: None,
|
||||
asset_metadata: None,
|
||||
sideswap_api_key: Some(SIDESWAP_API_KEY.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +170,7 @@ impl Config {
|
||||
use_default_external_input_parsers: true,
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: None,
|
||||
asset_metadata: None,
|
||||
sideswap_api_key: Some(SIDESWAP_API_KEY.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +195,7 @@ impl Config {
|
||||
use_default_external_input_parsers: true,
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: None,
|
||||
asset_metadata: None,
|
||||
sideswap_api_key: Some(SIDESWAP_API_KEY.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +219,7 @@ impl Config {
|
||||
use_default_external_input_parsers: true,
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: None,
|
||||
asset_metadata: None,
|
||||
sideswap_api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +244,7 @@ impl Config {
|
||||
use_default_external_input_parsers: true,
|
||||
onchain_fee_rate_leeway_sat_per_vbyte: None,
|
||||
asset_metadata: None,
|
||||
sideswap_api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +354,7 @@ impl Config {
|
||||
|
||||
/// Network chosen for this Liquid SDK instance. Note that it represents both the Liquid and the
|
||||
/// Bitcoin network used.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Serialize)]
|
||||
#[derive(Debug, Display, Copy, Clone, PartialEq, Serialize)]
|
||||
pub enum LiquidNetwork {
|
||||
/// Mainnet Bitcoin and Liquid chains
|
||||
Mainnet,
|
||||
@@ -707,13 +717,20 @@ pub enum SendDestination {
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PrepareSendResponse {
|
||||
pub destination: SendDestination,
|
||||
pub fees_sat: u64,
|
||||
/// The optional estimated fee in satoshi. Is set when there is Bitcoin available
|
||||
/// to pay fees. When not set, there are asset fees available to pay fees.
|
||||
pub fees_sat: Option<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].
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SendPaymentRequest {
|
||||
pub prepare_response: PrepareSendResponse,
|
||||
pub use_asset_fees: Option<bool>,
|
||||
}
|
||||
|
||||
/// Returned when calling [crate::sdk::LiquidSdk::send_payment].
|
||||
@@ -732,6 +749,7 @@ pub enum PayAmount {
|
||||
Asset {
|
||||
asset_id: String,
|
||||
receiver_amount: f64,
|
||||
estimate_asset_fees: Option<bool>,
|
||||
},
|
||||
|
||||
/// Indicates that all available Bitcoin funds should be sent
|
||||
@@ -837,9 +855,10 @@ impl WalletInfo {
|
||||
&self,
|
||||
network: LiquidNetwork,
|
||||
amount_sat: u64,
|
||||
fees_sat: u64,
|
||||
fees_sat: Option<u64>,
|
||||
asset_id: &str,
|
||||
) -> Result<(), PaymentError> {
|
||||
let fees_sat = fees_sat.unwrap_or(0);
|
||||
if asset_id.eq(&utils::lbtc_asset_id(network).to_string()) {
|
||||
ensure_sdk!(
|
||||
amount_sat + fees_sat <= self.balance_sat,
|
||||
@@ -1754,6 +1773,8 @@ pub struct AssetMetadata {
|
||||
/// The precision used to display the asset amount.
|
||||
/// For example, precision of 2 shifts the decimal 2 places left from the satoshi amount.
|
||||
pub precision: u8,
|
||||
/// The optional ID of the fiat currency used to represent the asset
|
||||
pub fiat_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AssetMetadata {
|
||||
@@ -1777,6 +1798,9 @@ pub struct AssetInfo {
|
||||
/// The amount calculated from the satoshi amount of the transaction, having its
|
||||
/// decimal shifted to the left by the [precision](AssetMetadata::precision)
|
||||
pub amount: f64,
|
||||
/// The optional fees when paid using the asset, having its
|
||||
/// decimal shifted to the left by the [precision](AssetMetadata::precision)
|
||||
pub fees: Option<f64>,
|
||||
}
|
||||
|
||||
/// The specific details of a payment, depending on its type
|
||||
@@ -2020,10 +2044,21 @@ impl Payment {
|
||||
s.payer_amount_sat.saturating_sub(s.receiver_amount_sat),
|
||||
),
|
||||
},
|
||||
None => match tx.payment_type {
|
||||
None => {
|
||||
let (amount_sat, fees_sat) = match tx.payment_type {
|
||||
PaymentType::Receive => (tx.amount, 0),
|
||||
PaymentType::Send => (tx.amount, tx.fees_sat),
|
||||
},
|
||||
};
|
||||
// If the payment is a Liquid payment, we only show the amount if the asset
|
||||
// is LBTC and only show the fees if the asset info has no set fees
|
||||
match details {
|
||||
PaymentDetails::Liquid {
|
||||
asset_info: Some(ref asset_info),
|
||||
..
|
||||
} if asset_info.ticker != "BTC" => (0, asset_info.fees.map_or(fees_sat, |_| 0)),
|
||||
_ => (amount_sat, fees_sat),
|
||||
}
|
||||
}
|
||||
};
|
||||
Payment {
|
||||
tx_id: Some(tx.tx_id),
|
||||
|
||||
73
lib/core/src/payjoin/error.rs
Normal file
73
lib/core/src/payjoin/error.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
29
lib/core/src/payjoin/mod.rs
Normal file
29
lib/core/src/payjoin/mod.rs
Normal 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)>;
|
||||
}
|
||||
107
lib/core/src/payjoin/model.rs
Normal file
107
lib/core/src/payjoin/model.rs
Normal 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,
|
||||
}
|
||||
113
lib/core/src/payjoin/network_fee.rs
Normal file
113
lib/core/src/payjoin/network_fee.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
270
lib/core/src/payjoin/pset/blind.rs
Normal file
270
lib/core/src/payjoin/pset/blind.rs
Normal 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(())
|
||||
}
|
||||
121
lib/core/src/payjoin/pset/mod.rs
Normal file
121
lib/core/src/payjoin/pset/mod.rs
Normal 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)
|
||||
}
|
||||
200
lib/core/src/payjoin/pset/tests.rs
Normal file
200
lib/core/src/payjoin/pset/tests.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
681
lib/core/src/payjoin/side_swap.rs
Normal file
681
lib/core/src/payjoin/side_swap.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
95
lib/core/src/payjoin/utxo_select/asset.rs
Normal file
95
lib/core/src/payjoin/utxo_select/asset.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
465
lib/core/src/payjoin/utxo_select/mod.rs
Normal file
465
lib/core/src/payjoin/utxo_select/mod.rs
Normal 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(())
|
||||
}
|
||||
388
lib/core/src/payjoin/utxo_select/tests.rs
Normal file
388
lib/core/src/payjoin/utxo_select/tests.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ impl Persister {
|
||||
if let Some(asset_metadata) = asset_metadata {
|
||||
for am in asset_metadata {
|
||||
con.execute(
|
||||
"INSERT INTO asset_metadata (asset_id, name, ticker, precision) VALUES (?, ?, ?, ?)",
|
||||
(am.asset_id, am.name, am.ticker, am.precision),
|
||||
"INSERT INTO asset_metadata (asset_id, name, ticker, precision, fiat_id) VALUES (?, ?, ?, ?, ?)",
|
||||
(am.asset_id, am.name, am.ticker, am.precision, am.fiat_id),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ impl Persister {
|
||||
"SELECT asset_id,
|
||||
name,
|
||||
ticker,
|
||||
precision
|
||||
precision,
|
||||
fiat_id
|
||||
FROM asset_metadata",
|
||||
)?;
|
||||
let asset_metadata: Vec<AssetMetadata> = stmt
|
||||
@@ -44,7 +45,8 @@ impl Persister {
|
||||
"SELECT asset_id,
|
||||
name,
|
||||
ticker,
|
||||
precision
|
||||
precision,
|
||||
fiat_id
|
||||
FROM asset_metadata
|
||||
WHERE asset_id = ?",
|
||||
)?;
|
||||
@@ -59,6 +61,7 @@ impl Persister {
|
||||
name: row.get(1)?,
|
||||
ticker: row.get(2)?,
|
||||
precision: row.get(3)?,
|
||||
fiat_id: row.get(4)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ pub(crate) fn current_migrations(network: LiquidNetwork) -> Vec<&'static str> {
|
||||
('5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225', 'Regtest Bitcoin', 'BTC', 8, 1);
|
||||
"
|
||||
};
|
||||
let update_asset_metadata_fiat_id = match network {
|
||||
LiquidNetwork::Mainnet => "UPDATE asset_metadata SET fiat_id = 'USD' WHERE asset_id = 'ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2';",
|
||||
LiquidNetwork::Testnet => "UPDATE asset_metadata SET fiat_id = 'USD' WHERE asset_id = 'b612eb46313a2cd6ebabd8b7a8eed5696e29898b87a43bff41c94f51acef9d73';",
|
||||
LiquidNetwork::Regtest => ";",
|
||||
};
|
||||
vec![
|
||||
"CREATE TABLE IF NOT EXISTS receive_swaps (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
@@ -315,5 +320,8 @@ pub(crate) fn current_migrations(network: LiquidNetwork) -> Vec<&'static str> {
|
||||
WHERE id = NEW.id;
|
||||
END;
|
||||
",
|
||||
"ALTER TABLE asset_metadata ADD COLUMN fiat_id TEXT;",
|
||||
update_asset_metadata_fiat_id,
|
||||
"ALTER TABLE payment_details ADD COLUMN asset_fees INTEGER;",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -341,15 +341,17 @@ impl Persister {
|
||||
destination,
|
||||
description,
|
||||
lnurl_info_json,
|
||||
bip353_address
|
||||
bip353_address,
|
||||
asset_fees
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (tx_id)
|
||||
DO UPDATE SET
|
||||
{destination_update}
|
||||
description = COALESCE(excluded.description, description),
|
||||
lnurl_info_json = COALESCE(excluded.lnurl_info_json, lnurl_info_json),
|
||||
bip353_address = COALESCE(excluded.bip353_address, bip353_address)
|
||||
bip353_address = COALESCE(excluded.bip353_address, bip353_address),
|
||||
asset_fees = COALESCE(excluded.asset_fees, asset_fees)
|
||||
"
|
||||
),
|
||||
(
|
||||
@@ -361,6 +363,7 @@ impl Persister {
|
||||
.as_ref()
|
||||
.map(|info| serde_json::to_string(&info).ok()),
|
||||
&payment_tx_details.bip353_address,
|
||||
&payment_tx_details.asset_fees,
|
||||
),
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -389,7 +392,7 @@ impl Persister {
|
||||
pub(crate) fn get_payment_details(&self, tx_id: &str) -> Result<Option<PaymentTxDetails>> {
|
||||
let con = self.get_connection()?;
|
||||
let mut stmt = con.prepare(
|
||||
"SELECT destination, description, lnurl_info_json, bip353_address
|
||||
"SELECT destination, description, lnurl_info_json, bip353_address, asset_fees
|
||||
FROM payment_details
|
||||
WHERE tx_id = ?",
|
||||
)?;
|
||||
@@ -398,6 +401,7 @@ impl Persister {
|
||||
let description = row.get(1)?;
|
||||
let maybe_lnurl_info_json: Option<String> = row.get(2)?;
|
||||
let maybe_bip353_address = row.get(3)?;
|
||||
let maybe_asset_fees = row.get(4)?;
|
||||
Ok(PaymentTxDetails {
|
||||
tx_id: tx_id.to_string(),
|
||||
destination,
|
||||
@@ -405,6 +409,7 @@ impl Persister {
|
||||
lnurl_info: maybe_lnurl_info_json
|
||||
.and_then(|info| serde_json::from_str::<LnUrlInfo>(&info).ok()),
|
||||
bip353_address: maybe_bip353_address,
|
||||
asset_fees: maybe_asset_fees,
|
||||
})
|
||||
});
|
||||
Ok(res.ok())
|
||||
@@ -500,6 +505,7 @@ impl Persister {
|
||||
pd.description,
|
||||
pd.lnurl_info_json,
|
||||
pd.bip353_address,
|
||||
pd.asset_fees,
|
||||
am.name,
|
||||
am.ticker,
|
||||
am.precision
|
||||
@@ -622,10 +628,11 @@ impl Persister {
|
||||
let maybe_payment_details_lnurl_info: Option<LnUrlInfo> =
|
||||
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_asset_fees: Option<u64> = row.get(56)?;
|
||||
|
||||
let maybe_asset_metadata_name: Option<String> = row.get(56)?;
|
||||
let maybe_asset_metadata_ticker: Option<String> = row.get(57)?;
|
||||
let maybe_asset_metadata_precision: Option<u8> = row.get(58)?;
|
||||
let maybe_asset_metadata_name: Option<String> = row.get(57)?;
|
||||
let maybe_asset_metadata_ticker: Option<String> = row.get(58)?;
|
||||
let maybe_asset_metadata_precision: Option<u8> = row.get(59)?;
|
||||
|
||||
let (swap, payment_type) = match maybe_receive_swap_id {
|
||||
Some(receive_swap_id) => {
|
||||
@@ -844,12 +851,21 @@ impl Persister {
|
||||
name: name.clone(),
|
||||
ticker: ticker.clone(),
|
||||
precision,
|
||||
fiat_id: None,
|
||||
};
|
||||
let (amount, fees) =
|
||||
maybe_payment_details_asset_fees.map_or((amount, None), |fees| {
|
||||
(
|
||||
amount.saturating_sub(fees),
|
||||
Some(asset_metadata.amount_from_sat(fees)),
|
||||
)
|
||||
});
|
||||
|
||||
Some(AssetInfo {
|
||||
name,
|
||||
ticker,
|
||||
amount: asset_metadata.amount_from_sat(amount),
|
||||
fees,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -7,4 +7,5 @@ pub(crate) struct PaymentTxDetails {
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) lnurl_info: Option<LnUrlInfo>,
|
||||
pub(crate) bip353_address: Option<String>,
|
||||
pub(crate) asset_fees: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ use crate::error::SdkError;
|
||||
use crate::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription};
|
||||
use crate::model::PaymentState::*;
|
||||
use crate::model::Signer;
|
||||
use crate::payjoin::{side_swap::SideSwapPayjoinService, PayjoinService};
|
||||
use crate::receive_swap::ReceiveSwapHandler;
|
||||
use crate::send_swap::SendSwapHandler;
|
||||
use crate::swapper::SubscriptionHandler;
|
||||
@@ -77,6 +78,7 @@ pub struct LiquidSdkBuilder {
|
||||
bitcoin_chain_service: Option<Arc<dyn BitcoinChainService>>,
|
||||
liquid_chain_service: Option<Arc<dyn LiquidChainService>>,
|
||||
onchain_wallet: Option<Arc<dyn OnchainWallet>>,
|
||||
payjoin_service: Option<Arc<dyn PayjoinService>>,
|
||||
persister: Option<Arc<Persister>>,
|
||||
recoverer: Option<Arc<Recoverer>>,
|
||||
rest_client: Option<Arc<dyn RestClient>>,
|
||||
@@ -100,6 +102,7 @@ impl LiquidSdkBuilder {
|
||||
bitcoin_chain_service: None,
|
||||
liquid_chain_service: None,
|
||||
onchain_wallet: None,
|
||||
payjoin_service: None,
|
||||
persister: None,
|
||||
recoverer: None,
|
||||
rest_client: None,
|
||||
@@ -135,6 +138,11 @@ impl LiquidSdkBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn payjoin_service(&mut self, payjoin_service: Arc<dyn PayjoinService>) -> &mut Self {
|
||||
self.payjoin_service = Some(payjoin_service.clone());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn persister(&mut self, persister: Arc<Persister>) -> &mut Self {
|
||||
self.persister = Some(persister.clone());
|
||||
self
|
||||
@@ -307,6 +315,17 @@ impl LiquidSdkBuilder {
|
||||
bitcoin_chain_service.clone(),
|
||||
)?);
|
||||
|
||||
let payjoin_service = match self.payjoin_service.clone() {
|
||||
Some(payjoin_service) => payjoin_service,
|
||||
None => Arc::new(SideSwapPayjoinService::new(
|
||||
self.config.clone(),
|
||||
self.breez_server.clone(),
|
||||
persister.clone(),
|
||||
onchain_wallet.clone(),
|
||||
rest_client.clone(),
|
||||
)),
|
||||
};
|
||||
|
||||
let buy_bitcoin_service = Arc::new(BuyBitcoinService::new(
|
||||
self.config.clone(),
|
||||
self.breez_server.clone(),
|
||||
@@ -334,6 +353,7 @@ impl LiquidSdkBuilder {
|
||||
receive_swap_handler,
|
||||
sync_service,
|
||||
chain_swap_handler,
|
||||
payjoin_service,
|
||||
buy_bitcoin_service,
|
||||
external_input_parsers,
|
||||
});
|
||||
@@ -361,6 +381,7 @@ pub struct LiquidSdk {
|
||||
pub(crate) sync_service: Option<Arc<SyncService>>,
|
||||
pub(crate) receive_swap_handler: ReceiveSwapHandler,
|
||||
pub(crate) chain_swap_handler: Arc<ChainSwapHandler>,
|
||||
pub(crate) payjoin_service: Arc<dyn PayjoinService>,
|
||||
pub(crate) buy_bitcoin_service: Arc<dyn BuyBitcoinApi>,
|
||||
pub(crate) external_input_parsers: Vec<ExternalInputParser>,
|
||||
}
|
||||
@@ -1135,7 +1156,11 @@ impl LiquidSdk {
|
||||
/// # Returns
|
||||
/// Returns a [PrepareSendResponse] containing:
|
||||
/// * `destination` - the parsed destination, of type [SendDestination]
|
||||
/// * `fees_sat` - the additional fees which will be paid by the sender
|
||||
/// * `fees_sat` - the optional estimated fee in satoshi. Is set when there is Bitcoin
|
||||
/// available to pay fees. When not set, there are asset fees available to pay fees.
|
||||
/// * `estimated_asset_fees` - the optional estimated fee in the asset. Is set when
|
||||
/// [PayAmount::Asset::estimate_asset_fees] is set to `true`, the Payjoin service accepts
|
||||
/// this asset to pay fees and there are funds available in this asset to pay fees.
|
||||
pub async fn prepare_send_payment(
|
||||
&self,
|
||||
req: &PrepareSendRequest,
|
||||
@@ -1144,6 +1169,7 @@ impl LiquidSdk {
|
||||
|
||||
let get_info_res = self.get_info().await?;
|
||||
let fees_sat;
|
||||
let estimated_asset_fees;
|
||||
let receiver_amount_sat;
|
||||
let asset_id;
|
||||
let payment_destination;
|
||||
@@ -1167,6 +1193,7 @@ impl LiquidSdk {
|
||||
PayAmount::Asset {
|
||||
asset_id,
|
||||
receiver_amount: amount,
|
||||
estimate_asset_fees: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1192,7 +1219,12 @@ impl LiquidSdk {
|
||||
}
|
||||
);
|
||||
|
||||
(asset_id, receiver_amount_sat, fees_sat) = match amount {
|
||||
(
|
||||
asset_id,
|
||||
receiver_amount_sat,
|
||||
fees_sat,
|
||||
estimated_asset_fees,
|
||||
) = match amount {
|
||||
PayAmount::Drain => {
|
||||
ensure_sdk!(
|
||||
get_info_res.wallet_info.pending_receive_sat == 0
|
||||
@@ -1210,7 +1242,8 @@ impl LiquidSdk {
|
||||
(
|
||||
self.config.lbtc_asset_id(),
|
||||
drain_amount_sat,
|
||||
drain_fees_sat,
|
||||
Some(drain_fees_sat),
|
||||
None,
|
||||
)
|
||||
}
|
||||
PayAmount::Bitcoin {
|
||||
@@ -1224,26 +1257,47 @@ impl LiquidSdk {
|
||||
&asset_id,
|
||||
)
|
||||
.await?;
|
||||
(asset_id, receiver_amount_sat, fees_sat)
|
||||
(asset_id, receiver_amount_sat, Some(fees_sat), None)
|
||||
}
|
||||
PayAmount::Asset {
|
||||
asset_id,
|
||||
receiver_amount,
|
||||
estimate_asset_fees,
|
||||
} => {
|
||||
let estimate_asset_fees = estimate_asset_fees.unwrap_or(false);
|
||||
let asset_metadata = self.persister.get_asset_metadata(&asset_id)?.ok_or(
|
||||
PaymentError::AssetError {
|
||||
err: format!("Asset {asset_id} is not supported"),
|
||||
},
|
||||
)?;
|
||||
let receiver_amount_sat = asset_metadata.amount_to_sat(receiver_amount);
|
||||
let fees_sat = self
|
||||
let fees_sat_res = self
|
||||
.estimate_onchain_tx_or_drain_tx_fee(
|
||||
receiver_amount_sat,
|
||||
&liquid_address_data.address,
|
||||
&asset_id,
|
||||
)
|
||||
.await?;
|
||||
(asset_id, receiver_amount_sat, fees_sat)
|
||||
.await;
|
||||
let asset_fees = if estimate_asset_fees {
|
||||
self.payjoin_service
|
||||
.estimate_payjoin_tx_fee(&asset_id, receiver_amount_sat)
|
||||
.await
|
||||
.inspect_err(|e| debug!("Error estimating payjoin tx: {e}"))
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (fees_sat, asset_fees) = match (fees_sat_res, asset_fees) {
|
||||
(Ok(fees_sat), _) => (Some(fees_sat), asset_fees),
|
||||
(Err(e), Some(asset_fees)) => {
|
||||
debug!(
|
||||
"Error estimating onchain tx, but returning payjoin fees: {e}"
|
||||
);
|
||||
(None, Some(asset_fees))
|
||||
}
|
||||
(Err(e), None) => return Err(e),
|
||||
};
|
||||
(asset_id, receiver_amount_sat, fees_sat, asset_fees)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1281,6 +1335,7 @@ impl LiquidSdk {
|
||||
.await?
|
||||
.map(|(address, _)| address);
|
||||
asset_id = self.config.lbtc_asset_id();
|
||||
estimated_asset_fees = None;
|
||||
(receiver_amount_sat, fees_sat, payment_destination) =
|
||||
match (mrh_address.clone(), req.amount.clone()) {
|
||||
(Some(lbtc_address), Some(PayAmount::Drain)) => {
|
||||
@@ -1303,7 +1358,7 @@ impl LiquidSdk {
|
||||
},
|
||||
bip353_address: None,
|
||||
};
|
||||
(drain_amount_sat, drain_fees_sat, payment_destination)
|
||||
(drain_amount_sat, Some(drain_fees_sat), payment_destination)
|
||||
}
|
||||
(Some(lbtc_address), _) => {
|
||||
// The BOLT11 invoice has an MRH but no drain is requested,
|
||||
@@ -1317,7 +1372,7 @@ impl LiquidSdk {
|
||||
.await?;
|
||||
(
|
||||
invoice_amount_sat,
|
||||
fees_sat,
|
||||
Some(fees_sat),
|
||||
SendDestination::Bolt11 {
|
||||
invoice,
|
||||
bip353_address: None,
|
||||
@@ -1334,7 +1389,7 @@ impl LiquidSdk {
|
||||
let fees_sat = boltz_fees_total + lockup_fees_sat;
|
||||
(
|
||||
invoice_amount_sat,
|
||||
fees_sat,
|
||||
Some(fees_sat),
|
||||
SendDestination::Bolt11 {
|
||||
invoice,
|
||||
bip353_address: None,
|
||||
@@ -1371,7 +1426,8 @@ impl LiquidSdk {
|
||||
.estimate_lockup_tx_or_drain_tx_fee(receiver_amount_sat + boltz_fees_total)
|
||||
.await?;
|
||||
asset_id = self.config.lbtc_asset_id();
|
||||
fees_sat = boltz_fees_total + lockup_fees_sat;
|
||||
fees_sat = Some(boltz_fees_total + lockup_fees_sat);
|
||||
estimated_asset_fees = None;
|
||||
|
||||
payment_destination = SendDestination::Bolt12 {
|
||||
offer,
|
||||
@@ -1394,6 +1450,7 @@ impl LiquidSdk {
|
||||
Ok(PrepareSendResponse {
|
||||
destination: payment_destination,
|
||||
fees_sat,
|
||||
estimated_asset_fees,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1428,6 +1485,7 @@ impl LiquidSdk {
|
||||
let PrepareSendResponse {
|
||||
fees_sat,
|
||||
destination: payment_destination,
|
||||
..
|
||||
} = &req.prepare_response;
|
||||
|
||||
match payment_destination {
|
||||
@@ -1435,6 +1493,7 @@ impl LiquidSdk {
|
||||
address_data: liquid_address_data,
|
||||
bip353_address,
|
||||
} => {
|
||||
let asset_pay_fees = req.use_asset_fees.unwrap_or_default();
|
||||
let Some(amount_sat) = liquid_address_data.amount_sat else {
|
||||
return Err(PaymentError::AmountMissing {
|
||||
err: "Amount must be set when paying to a Liquid address".to_string(),
|
||||
@@ -1466,9 +1525,16 @@ impl LiquidSdk {
|
||||
*fees_sat,
|
||||
asset_id,
|
||||
)?;
|
||||
let mut response = self
|
||||
.pay_liquid(liquid_address_data.clone(), amount_sat, *fees_sat, true)
|
||||
.await?;
|
||||
|
||||
let mut response = if asset_pay_fees {
|
||||
self.pay_liquid_payjoin(liquid_address_data.clone(), amount_sat)
|
||||
.await?
|
||||
} else {
|
||||
let fees_sat = fees_sat.ok_or(PaymentError::InsufficientFunds)?;
|
||||
self.pay_liquid(liquid_address_data.clone(), amount_sat, fees_sat, true)
|
||||
.await?
|
||||
};
|
||||
|
||||
self.insert_bip353_payment_details(bip353_address, &mut response)?;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -1476,7 +1542,8 @@ impl LiquidSdk {
|
||||
invoice,
|
||||
bip353_address,
|
||||
} => {
|
||||
let mut response = self.pay_bolt11_invoice(&invoice.bolt11, *fees_sat).await?;
|
||||
let fees_sat = fees_sat.ok_or(PaymentError::InsufficientFunds)?;
|
||||
let mut response = self.pay_bolt11_invoice(&invoice.bolt11, fees_sat).await?;
|
||||
self.insert_bip353_payment_details(bip353_address, &mut response)?;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -1485,12 +1552,13 @@ impl LiquidSdk {
|
||||
receiver_amount_sat,
|
||||
bip353_address,
|
||||
} => {
|
||||
let fees_sat = fees_sat.ok_or(PaymentError::InsufficientFunds)?;
|
||||
let bolt12_invoice = self
|
||||
.swapper
|
||||
.get_bolt12_invoice(&offer.offer, *receiver_amount_sat)
|
||||
.await?;
|
||||
let mut response = self
|
||||
.pay_bolt12_invoice(offer, *receiver_amount_sat, &bolt12_invoice, *fees_sat)
|
||||
.pay_bolt12_invoice(offer, *receiver_amount_sat, &bolt12_invoice, fees_sat)
|
||||
.await?;
|
||||
self.insert_bip353_payment_details(bip353_address, &mut response)?;
|
||||
Ok(response)
|
||||
@@ -1514,6 +1582,7 @@ impl LiquidSdk {
|
||||
description: None,
|
||||
lnurl_info: None,
|
||||
bip353_address: bip353_address.clone(),
|
||||
asset_fees: None,
|
||||
})?;
|
||||
// Get the payment with the bip353_address details
|
||||
if let Some(payment) = self.persister.get_payment(tx_id)? {
|
||||
@@ -1608,7 +1677,7 @@ impl LiquidSdk {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Performs a Send Payment by doing an onchain tx to a L-BTC address
|
||||
/// Performs a Send Payment by doing an onchain tx to a Liquid address
|
||||
async fn pay_liquid(
|
||||
&self,
|
||||
address_data: LiquidAddressData,
|
||||
@@ -1646,7 +1715,7 @@ impl LiquidSdk {
|
||||
ensure_sdk!(tx_fees_sat <= fees_sat, PaymentError::InvalidOrExpiredFees);
|
||||
|
||||
info!(
|
||||
"Built onchain L-BTC tx with receiver_amount_sat = {receiver_amount_sat}, fees_sat = {fees_sat} and txid = {tx_id}"
|
||||
"Built onchain Liquid tx with receiver_amount_sat = {receiver_amount_sat}, fees_sat = {fees_sat} and txid = {tx_id}"
|
||||
);
|
||||
|
||||
let tx_id = self.liquid_chain_service.broadcast(&tx).await?.to_string();
|
||||
@@ -1685,6 +1754,87 @@ impl LiquidSdk {
|
||||
name: am.name.clone(),
|
||||
ticker: am.ticker.clone(),
|
||||
amount: am.amount_from_sat(receiver_amount_sat),
|
||||
fees: None,
|
||||
});
|
||||
let payment_details = PaymentDetails::Liquid {
|
||||
asset_id,
|
||||
destination,
|
||||
description: description.unwrap_or("Liquid transfer".to_string()),
|
||||
asset_info,
|
||||
lnurl_info: None,
|
||||
bip353_address: None,
|
||||
};
|
||||
|
||||
Ok(SendPaymentResponse {
|
||||
payment: Payment::from_tx_data(tx_data, None, payment_details),
|
||||
})
|
||||
}
|
||||
|
||||
/// Performs a Send Payment by doing a payjoin tx to a Liquid address
|
||||
async fn pay_liquid_payjoin(
|
||||
&self,
|
||||
address_data: LiquidAddressData,
|
||||
receiver_amount_sat: u64,
|
||||
) -> Result<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 {
|
||||
asset_id,
|
||||
@@ -3513,10 +3663,13 @@ impl LiquidSdk {
|
||||
}
|
||||
destination => destination,
|
||||
};
|
||||
let fees_sat = prepare_response
|
||||
.fees_sat
|
||||
.ok_or(PaymentError::InsufficientFunds)?;
|
||||
|
||||
Ok(PrepareLnUrlPayResponse {
|
||||
destination,
|
||||
fees_sat: prepare_response.fees_sat,
|
||||
fees_sat,
|
||||
data: req.data,
|
||||
comment: req.comment,
|
||||
success_action: data.success_action,
|
||||
@@ -3546,8 +3699,10 @@ impl LiquidSdk {
|
||||
.send_payment(&SendPaymentRequest {
|
||||
prepare_response: PrepareSendResponse {
|
||||
destination: prepare_response.destination.clone(),
|
||||
fees_sat: prepare_response.fees_sat,
|
||||
fees_sat: Some(prepare_response.fees_sat),
|
||||
estimated_asset_fees: None,
|
||||
},
|
||||
use_asset_fees: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| LnUrlPayError::Generic { err: e.to_string() })?
|
||||
@@ -3633,6 +3788,7 @@ impl LiquidSdk {
|
||||
lnurl_withdraw_endpoint: None,
|
||||
}),
|
||||
bip353_address: None,
|
||||
asset_fees: None,
|
||||
})?;
|
||||
// Get the payment with the lnurl_info details
|
||||
payment = self.persister.get_payment(&tx_id)?.unwrap_or(payment);
|
||||
@@ -3701,6 +3857,7 @@ impl LiquidSdk {
|
||||
..Default::default()
|
||||
}),
|
||||
bip353_address: None,
|
||||
asset_fees: None,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +320,7 @@ impl SendSwapHandler {
|
||||
description,
|
||||
lnurl_info: Some(lnurl_info),
|
||||
bip353_address,
|
||||
asset_fees: None,
|
||||
})?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ pub(crate) struct PaymentDetailsSyncData {
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) lnurl_info: Option<LnUrlInfo>,
|
||||
pub(crate) bip353_address: Option<String>,
|
||||
pub(crate) asset_fees: Option<u64>,
|
||||
}
|
||||
|
||||
impl PaymentDetailsSyncData {
|
||||
@@ -315,6 +316,7 @@ impl PaymentDetailsSyncData {
|
||||
"description" => clone_if_set(&mut self.description, &other.description),
|
||||
"lnurl_info" => clone_if_set(&mut self.lnurl_info, &other.lnurl_info),
|
||||
"bip353_address" => clone_if_set(&mut self.bip353_address, &other.bip353_address),
|
||||
"asset_fees" => self.asset_fees = other.asset_fees,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
@@ -329,6 +331,7 @@ impl From<PaymentTxDetails> for PaymentDetailsSyncData {
|
||||
description: value.description,
|
||||
lnurl_info: value.lnurl_info,
|
||||
bip353_address: value.bip353_address,
|
||||
asset_fees: value.asset_fees,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,6 +344,7 @@ impl From<PaymentDetailsSyncData> for PaymentTxDetails {
|
||||
description: val.description,
|
||||
lnurl_info: val.lnurl_info,
|
||||
bip353_address: val.bip353_address,
|
||||
asset_fees: val.asset_fees,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub(crate) mod data;
|
||||
|
||||
const MESSAGE_PREFIX: &[u8; 13] = b"realtimesync:";
|
||||
lazy_static! {
|
||||
static ref CURRENT_SCHEMA_VERSION: Version = Version::parse("0.4.0").unwrap();
|
||||
static ref CURRENT_SCHEMA_VERSION: Version = Version::parse("0.5.0").unwrap();
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
use std::{collections::HashMap, str::FromStr, sync::Mutex};
|
||||
|
||||
use crate::{
|
||||
error::PaymentError,
|
||||
@@ -18,15 +18,16 @@ use lwk_wollet::{
|
||||
self,
|
||||
bip32::{DerivationPath, Xpriv, Xpub},
|
||||
},
|
||||
elements::{hex::ToHex, Address, Transaction, Txid},
|
||||
elements::{hex::ToHex, pset::PartiallySignedTransaction, Address, AssetId, Transaction, Txid},
|
||||
elements_miniscript::{slip77::MasterBlindingKey, ToPublicKey as _},
|
||||
secp256k1::{All, Message},
|
||||
WalletTx,
|
||||
WalletTx, WalletTxOut,
|
||||
};
|
||||
use sdk_common::utils::Arc;
|
||||
|
||||
pub(crate) struct MockWallet {
|
||||
signer: SdkLwkSigner,
|
||||
utxos: Mutex<Vec<WalletTxOut>>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
@@ -38,7 +39,15 @@ lazy_static! {
|
||||
impl MockWallet {
|
||||
pub(crate) fn new(user_signer: Arc<Box<dyn Signer>>) -> Result<Self> {
|
||||
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())
|
||||
}
|
||||
|
||||
async fn asset_utxos(&self, _asset_id: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError> {
|
||||
Ok(self.utxos.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn build_tx(
|
||||
&self,
|
||||
_fee_rate: Option<f32>,
|
||||
@@ -81,10 +94,21 @@ impl OnchainWallet for MockWallet {
|
||||
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> {
|
||||
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 {
|
||||
0
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ use log::{debug, info, warn};
|
||||
use lwk_common::Signer as LwkSigner;
|
||||
use lwk_common::{singlesig_desc, Singlesig};
|
||||
use lwk_wollet::asyncr::{EsploraClient, EsploraClientBuilder};
|
||||
use lwk_wollet::elements::{AssetId, Txid};
|
||||
use lwk_wollet::elements::hex::ToHex;
|
||||
use lwk_wollet::elements::pset::PartiallySignedTransaction;
|
||||
use lwk_wollet::elements::{Address, AssetId, OutPoint, Transaction, TxOut, Txid};
|
||||
use lwk_wollet::secp256k1::Message;
|
||||
use lwk_wollet::{
|
||||
elements::{hex::ToHex, Address, Transaction},
|
||||
ElementsNetwork, FsPersister, NoPersist, WalletTx, Wollet, WolletDescriptor,
|
||||
ElementsNetwork, FsPersister, NoPersist, WalletTx, WalletTxOut, Wollet, WolletDescriptor,
|
||||
};
|
||||
use maybe_sync::{MaybeSend, MaybeSync};
|
||||
use sdk_common::bitcoin::hashes::{sha256, Hash};
|
||||
@@ -44,6 +45,9 @@ pub trait OnchainWallet: MaybeSend + MaybeSync {
|
||||
/// List all transactions in the wallet mapped by tx id
|
||||
async fn transactions_by_tx_id(&self) -> Result<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
|
||||
async fn build_tx(
|
||||
&self,
|
||||
@@ -78,9 +82,18 @@ pub trait OnchainWallet: MaybeSend + MaybeSync {
|
||||
amount_sat: u64,
|
||||
) -> 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
|
||||
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
|
||||
async fn tip(&self) -> u32;
|
||||
|
||||
@@ -272,6 +285,18 @@ impl LiquidOnchainWallet {
|
||||
.map_err(|e| anyhow!("Invalid descriptor: {e}"))?;
|
||||
Ok(descriptor_str.parse()?)
|
||||
}
|
||||
|
||||
async fn get_txout(&self, wallet: &Wollet, outpoint: &OutPoint) -> Result<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]
|
||||
@@ -295,6 +320,17 @@ impl OnchainWallet for LiquidOnchainWallet {
|
||||
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
|
||||
async fn build_tx(
|
||||
&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
|
||||
async fn next_unused_address(&self) -> Result<Address, PaymentError> {
|
||||
let tip = self.tip().await;
|
||||
@@ -432,6 +512,13 @@ impl OnchainWallet for LiquidOnchainWallet {
|
||||
Ok(address)
|
||||
}
|
||||
|
||||
/// Get the next unused change address in the wallet
|
||||
async fn next_unused_change_address(&self) -> Result<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
|
||||
async fn tip(&self) -> u32 {
|
||||
self.wallet.lock().await.tip().height()
|
||||
|
||||
@@ -314,6 +314,7 @@ pub struct Config {
|
||||
pub use_default_external_input_parsers: bool,
|
||||
pub onchain_fee_rate_leeway_sat_per_vbyte: Option<u32>,
|
||||
pub asset_metadata: Option<Vec<AssetMetadata>>,
|
||||
pub sideswap_api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -443,12 +444,14 @@ pub enum SendDestination {
|
||||
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::PrepareSendResponse)]
|
||||
pub struct PrepareSendResponse {
|
||||
pub destination: SendDestination,
|
||||
pub fees_sat: u64,
|
||||
pub fees_sat: Option<u64>,
|
||||
pub estimated_asset_fees: Option<f64>,
|
||||
}
|
||||
|
||||
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::SendPaymentRequest)]
|
||||
pub struct SendPaymentRequest {
|
||||
pub prepare_response: PrepareSendResponse,
|
||||
pub use_asset_fees: Option<bool>,
|
||||
}
|
||||
|
||||
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::SendPaymentResponse)]
|
||||
@@ -464,6 +467,7 @@ pub enum PayAmount {
|
||||
Asset {
|
||||
asset_id: String,
|
||||
receiver_amount: f64,
|
||||
estimate_asset_fees: Option<bool>,
|
||||
},
|
||||
Drain,
|
||||
}
|
||||
@@ -655,6 +659,7 @@ pub struct AssetMetadata {
|
||||
pub name: String,
|
||||
pub ticker: String,
|
||||
pub precision: u8,
|
||||
pub fiat_id: Option<String>,
|
||||
}
|
||||
|
||||
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::AssetInfo)]
|
||||
@@ -662,6 +667,7 @@ pub struct AssetInfo {
|
||||
pub name: String,
|
||||
pub ticker: String,
|
||||
pub amount: f64,
|
||||
pub fees: Option<f64>,
|
||||
}
|
||||
|
||||
#[sdk_macros::extern_wasm_bindgen(breez_sdk_liquid::prelude::PaymentDetails)]
|
||||
|
||||
@@ -1476,11 +1476,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
AssetInfo dco_decode_asset_info(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<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(
|
||||
name: dco_decode_String(arr[0]),
|
||||
ticker: dco_decode_String(arr[1]),
|
||||
amount: dco_decode_f_64(arr[2]),
|
||||
fees: dco_decode_opt_box_autoadd_f_64(arr[3]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1488,12 +1489,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
AssetMetadata dco_decode_asset_metadata(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<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(
|
||||
assetId: dco_decode_String(arr[0]),
|
||||
name: dco_decode_String(arr[1]),
|
||||
ticker: dco_decode_String(arr[2]),
|
||||
precision: dco_decode_u_8(arr[3]),
|
||||
fiatId: dco_decode_opt_String(arr[4]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1928,7 +1930,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
Config dco_decode_config(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<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(
|
||||
liquidExplorer: dco_decode_blockchain_explorer(arr[0]),
|
||||
bitcoinExplorer: dco_decode_blockchain_explorer(arr[1]),
|
||||
@@ -1943,6 +1945,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
useDefaultExternalInputParsers: dco_decode_bool(arr[10]),
|
||||
onchainFeeRateLeewaySatPerVbyte: dco_decode_opt_box_autoadd_u_32(arr[11]),
|
||||
assetMetadata: dco_decode_opt_list_asset_metadata(arr[12]),
|
||||
sideswapApiKey: dco_decode_opt_String(arr[13]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2724,7 +2727,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
case 0:
|
||||
return PayAmount_Bitcoin(receiverAmountSat: dco_decode_u_64(raw[1]));
|
||||
case 1:
|
||||
return PayAmount_Asset(assetId: dco_decode_String(raw[1]), receiverAmount: dco_decode_f_64(raw[2]));
|
||||
return PayAmount_Asset(
|
||||
assetId: dco_decode_String(raw[1]),
|
||||
receiverAmount: dco_decode_f_64(raw[2]),
|
||||
estimateAssetFees: dco_decode_opt_box_autoadd_bool(raw[3]),
|
||||
);
|
||||
case 2:
|
||||
return PayAmount_Drain();
|
||||
default:
|
||||
@@ -3015,10 +3022,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
PrepareSendResponse dco_decode_prepare_send_response(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<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(
|
||||
destination: dco_decode_send_destination(arr[0]),
|
||||
feesSat: dco_decode_u_64(arr[1]),
|
||||
feesSat: dco_decode_opt_box_autoadd_u_64(arr[1]),
|
||||
estimatedAssetFees: dco_decode_opt_box_autoadd_f_64(arr[2]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3220,8 +3228,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SendPaymentRequest dco_decode_send_payment_request(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 1) throw Exception('unexpected arr length: expect 1 but see ${arr.length}');
|
||||
return SendPaymentRequest(prepareResponse: dco_decode_prepare_send_response(arr[0]));
|
||||
if (arr.length != 2) throw Exception('unexpected arr length: expect 2 but see ${arr.length}');
|
||||
return SendPaymentRequest(
|
||||
prepareResponse: dco_decode_prepare_send_response(arr[0]),
|
||||
useAssetFees: dco_decode_opt_box_autoadd_bool(arr[1]),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -3503,7 +3514,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_name = sse_decode_String(deserializer);
|
||||
var var_ticker = sse_decode_String(deserializer);
|
||||
var var_amount = sse_decode_f_64(deserializer);
|
||||
return AssetInfo(name: var_name, ticker: var_ticker, amount: var_amount);
|
||||
var var_fees = sse_decode_opt_box_autoadd_f_64(deserializer);
|
||||
return AssetInfo(name: var_name, ticker: var_ticker, amount: var_amount, fees: var_fees);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -3513,7 +3525,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_name = sse_decode_String(deserializer);
|
||||
var var_ticker = sse_decode_String(deserializer);
|
||||
var var_precision = sse_decode_u_8(deserializer);
|
||||
return AssetMetadata(assetId: var_assetId, name: var_name, ticker: var_ticker, precision: var_precision);
|
||||
var var_fiatId = sse_decode_opt_String(deserializer);
|
||||
return AssetMetadata(
|
||||
assetId: var_assetId,
|
||||
name: var_name,
|
||||
ticker: var_ticker,
|
||||
precision: var_precision,
|
||||
fiatId: var_fiatId,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -3964,6 +3983,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_useDefaultExternalInputParsers = sse_decode_bool(deserializer);
|
||||
var var_onchainFeeRateLeewaySatPerVbyte = sse_decode_opt_box_autoadd_u_32(deserializer);
|
||||
var var_assetMetadata = sse_decode_opt_list_asset_metadata(deserializer);
|
||||
var var_sideswapApiKey = sse_decode_opt_String(deserializer);
|
||||
return Config(
|
||||
liquidExplorer: var_liquidExplorer,
|
||||
bitcoinExplorer: var_bitcoinExplorer,
|
||||
@@ -3978,6 +3998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
useDefaultExternalInputParsers: var_useDefaultExternalInputParsers,
|
||||
onchainFeeRateLeewaySatPerVbyte: var_onchainFeeRateLeewaySatPerVbyte,
|
||||
assetMetadata: var_assetMetadata,
|
||||
sideswapApiKey: var_sideswapApiKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5044,7 +5065,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
case 1:
|
||||
var var_assetId = sse_decode_String(deserializer);
|
||||
var var_receiverAmount = sse_decode_f_64(deserializer);
|
||||
return PayAmount_Asset(assetId: var_assetId, receiverAmount: var_receiverAmount);
|
||||
var var_estimateAssetFees = sse_decode_opt_box_autoadd_bool(deserializer);
|
||||
return PayAmount_Asset(
|
||||
assetId: var_assetId,
|
||||
receiverAmount: var_receiverAmount,
|
||||
estimateAssetFees: var_estimateAssetFees,
|
||||
);
|
||||
case 2:
|
||||
return PayAmount_Drain();
|
||||
default:
|
||||
@@ -5383,8 +5409,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
PrepareSendResponse sse_decode_prepare_send_response(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var var_destination = sse_decode_send_destination(deserializer);
|
||||
var var_feesSat = sse_decode_u_64(deserializer);
|
||||
return PrepareSendResponse(destination: var_destination, feesSat: var_feesSat);
|
||||
var var_feesSat = sse_decode_opt_box_autoadd_u_64(deserializer);
|
||||
var var_estimatedAssetFees = sse_decode_opt_box_autoadd_f_64(deserializer);
|
||||
return PrepareSendResponse(
|
||||
destination: var_destination,
|
||||
feesSat: var_feesSat,
|
||||
estimatedAssetFees: var_estimatedAssetFees,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -5613,7 +5644,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SendPaymentRequest sse_decode_send_payment_request(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var var_prepareResponse = sse_decode_prepare_send_response(deserializer);
|
||||
return SendPaymentRequest(prepareResponse: var_prepareResponse);
|
||||
var var_useAssetFees = sse_decode_opt_box_autoadd_bool(deserializer);
|
||||
return SendPaymentRequest(prepareResponse: var_prepareResponse, useAssetFees: var_useAssetFees);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -5990,6 +6022,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_String(self.name, serializer);
|
||||
sse_encode_String(self.ticker, serializer);
|
||||
sse_encode_f_64(self.amount, serializer);
|
||||
sse_encode_opt_box_autoadd_f_64(self.fees, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -5999,6 +6032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_String(self.name, serializer);
|
||||
sse_encode_String(self.ticker, serializer);
|
||||
sse_encode_u_8(self.precision, serializer);
|
||||
sse_encode_opt_String(self.fiatId, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -6456,6 +6490,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_bool(self.useDefaultExternalInputParsers, serializer);
|
||||
sse_encode_opt_box_autoadd_u_32(self.onchainFeeRateLeewaySatPerVbyte, serializer);
|
||||
sse_encode_opt_list_asset_metadata(self.assetMetadata, serializer);
|
||||
sse_encode_opt_String(self.sideswapApiKey, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -7309,10 +7344,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
case PayAmount_Bitcoin(receiverAmountSat: final receiverAmountSat):
|
||||
sse_encode_i_32(0, serializer);
|
||||
sse_encode_u_64(receiverAmountSat, serializer);
|
||||
case PayAmount_Asset(assetId: final assetId, receiverAmount: final receiverAmount):
|
||||
case PayAmount_Asset(
|
||||
assetId: final assetId,
|
||||
receiverAmount: final receiverAmount,
|
||||
estimateAssetFees: final estimateAssetFees,
|
||||
):
|
||||
sse_encode_i_32(1, serializer);
|
||||
sse_encode_String(assetId, serializer);
|
||||
sse_encode_f_64(receiverAmount, serializer);
|
||||
sse_encode_opt_box_autoadd_bool(estimateAssetFees, serializer);
|
||||
case PayAmount_Drain():
|
||||
sse_encode_i_32(2, serializer);
|
||||
}
|
||||
@@ -7583,7 +7623,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
void sse_encode_prepare_send_response(PrepareSendResponse self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_send_destination(self.destination, serializer);
|
||||
sse_encode_u_64(self.feesSat, serializer);
|
||||
sse_encode_opt_box_autoadd_u_64(self.feesSat, serializer);
|
||||
sse_encode_opt_box_autoadd_f_64(self.estimatedAssetFees, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -7759,6 +7800,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
void sse_encode_send_payment_request(SendPaymentRequest self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_prepare_send_response(self.prepareResponse, serializer);
|
||||
sse_encode_opt_box_autoadd_bool(self.useAssetFees, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
|
||||
@@ -2233,6 +2233,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
wireObj.name = cst_encode_String(apiObj.name);
|
||||
wireObj.ticker = cst_encode_String(apiObj.ticker);
|
||||
wireObj.amount = cst_encode_f_64(apiObj.amount);
|
||||
wireObj.fees = cst_encode_opt_box_autoadd_f_64(apiObj.fees);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -2241,6 +2242,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
wireObj.name = cst_encode_String(apiObj.name);
|
||||
wireObj.ticker = cst_encode_String(apiObj.ticker);
|
||||
wireObj.precision = cst_encode_u_8(apiObj.precision);
|
||||
wireObj.fiat_id = cst_encode_opt_String(apiObj.fiatId);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -2724,6 +2726,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
apiObj.onchainFeeRateLeewaySatPerVbyte,
|
||||
);
|
||||
wireObj.asset_metadata = cst_encode_opt_list_asset_metadata(apiObj.assetMetadata);
|
||||
wireObj.sideswap_api_key = cst_encode_opt_String(apiObj.sideswapApiKey);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -3325,9 +3328,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
if (apiObj is PayAmount_Asset) {
|
||||
var pre_asset_id = cst_encode_String(apiObj.assetId);
|
||||
var pre_receiver_amount = cst_encode_f_64(apiObj.receiverAmount);
|
||||
var pre_estimate_asset_fees = cst_encode_opt_box_autoadd_bool(apiObj.estimateAssetFees);
|
||||
wireObj.tag = 1;
|
||||
wireObj.kind.Asset.asset_id = pre_asset_id;
|
||||
wireObj.kind.Asset.receiver_amount = pre_receiver_amount;
|
||||
wireObj.kind.Asset.estimate_asset_fees = pre_estimate_asset_fees;
|
||||
return;
|
||||
}
|
||||
if (apiObj is PayAmount_Drain) {
|
||||
@@ -3662,7 +3667,8 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
wire_cst_prepare_send_response wireObj,
|
||||
) {
|
||||
cst_api_fill_to_wire_send_destination(apiObj.destination, wireObj.destination);
|
||||
wireObj.fees_sat = cst_encode_u_64(apiObj.feesSat);
|
||||
wireObj.fees_sat = cst_encode_opt_box_autoadd_u_64(apiObj.feesSat);
|
||||
wireObj.estimated_asset_fees = cst_encode_opt_box_autoadd_f_64(apiObj.estimatedAssetFees);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -3879,6 +3885,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
wire_cst_send_payment_request wireObj,
|
||||
) {
|
||||
cst_api_fill_to_wire_prepare_send_response(apiObj.prepareResponse, wireObj.prepare_response);
|
||||
wireObj.use_asset_fees = cst_encode_opt_box_autoadd_bool(apiObj.useAssetFees);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -6703,6 +6710,8 @@ final class wire_cst_PayAmount_Asset extends ffi.Struct {
|
||||
|
||||
@ffi.Double()
|
||||
external double receiver_amount;
|
||||
|
||||
external ffi.Pointer<ffi.Bool> estimate_asset_fees;
|
||||
}
|
||||
|
||||
final class PayAmountKind extends ffi.Union {
|
||||
@@ -6822,12 +6831,15 @@ final class wire_cst_restore_request extends ffi.Struct {
|
||||
final class wire_cst_prepare_send_response extends ffi.Struct {
|
||||
external wire_cst_send_destination destination;
|
||||
|
||||
@ffi.Uint64()
|
||||
external int fees_sat;
|
||||
external ffi.Pointer<ffi.Uint64> fees_sat;
|
||||
|
||||
external ffi.Pointer<ffi.Double> estimated_asset_fees;
|
||||
}
|
||||
|
||||
final class wire_cst_send_payment_request extends ffi.Struct {
|
||||
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 {
|
||||
@@ -6944,6 +6956,8 @@ final class wire_cst_asset_info extends ffi.Struct {
|
||||
|
||||
@ffi.Double()
|
||||
external double amount;
|
||||
|
||||
external ffi.Pointer<ffi.Double> fees;
|
||||
}
|
||||
|
||||
final class wire_cst_PaymentDetails_Liquid extends ffi.Struct {
|
||||
@@ -7133,6 +7147,8 @@ final class wire_cst_asset_metadata extends ffi.Struct {
|
||||
|
||||
@ffi.Uint8()
|
||||
external int precision;
|
||||
|
||||
external ffi.Pointer<wire_cst_list_prim_u_8_strict> fiat_id;
|
||||
}
|
||||
|
||||
final class wire_cst_list_asset_metadata extends ffi.Struct {
|
||||
@@ -7171,6 +7187,8 @@ final class wire_cst_config extends ffi.Struct {
|
||||
external ffi.Pointer<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_prim_u_8_strict> sideswap_api_key;
|
||||
}
|
||||
|
||||
final class wire_cst_connect_request extends ffi.Struct {
|
||||
@@ -7841,6 +7859,16 @@ const double LIQUID_FEE_RATE_SAT_PER_VBYTE = 0.1;
|
||||
|
||||
const double LIQUID_FEE_RATE_MSAT_PER_VBYTE = 100.0;
|
||||
|
||||
const double MIN_FEE_RATE = 0.1;
|
||||
|
||||
const int WEIGHT_FIXED = 222;
|
||||
|
||||
const int WEIGHT_VIN_SINGLE_SIG_NATIVE = 275;
|
||||
|
||||
const int WEIGHT_VIN_SINGLE_SIG_NESTED = 367;
|
||||
|
||||
const int WEIGHT_VOUT_NESTED = 270;
|
||||
|
||||
const int DEFAULT_ZERO_CONF_MAX_SAT = 1000000;
|
||||
|
||||
const int CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS = 4320;
|
||||
|
||||
@@ -65,10 +65,14 @@ class AssetInfo {
|
||||
/// decimal shifted to the left by the [precision](AssetMetadata::precision)
|
||||
final double amount;
|
||||
|
||||
const AssetInfo({required this.name, required this.ticker, required this.amount});
|
||||
/// The optional fees when paid using the asset, having its
|
||||
/// decimal shifted to the left by the [precision](AssetMetadata::precision)
|
||||
final double? fees;
|
||||
|
||||
const AssetInfo({required this.name, required this.ticker, required this.amount, this.fees});
|
||||
|
||||
@override
|
||||
int get hashCode => name.hashCode ^ ticker.hashCode ^ amount.hashCode;
|
||||
int get hashCode => name.hashCode ^ ticker.hashCode ^ amount.hashCode ^ fees.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -77,7 +81,8 @@ class AssetInfo {
|
||||
runtimeType == other.runtimeType &&
|
||||
name == other.name &&
|
||||
ticker == other.ticker &&
|
||||
amount == other.amount;
|
||||
amount == other.amount &&
|
||||
fees == other.fees;
|
||||
}
|
||||
|
||||
/// Configuration for asset metadata. Each asset metadata item represents an entry in the
|
||||
@@ -97,15 +102,20 @@ class AssetMetadata {
|
||||
/// For example, precision of 2 shifts the decimal 2 places left from the satoshi amount.
|
||||
final int precision;
|
||||
|
||||
/// The optional ID of the fiat currency used to represent the asset
|
||||
final String? fiatId;
|
||||
|
||||
const AssetMetadata({
|
||||
required this.assetId,
|
||||
required this.name,
|
||||
required this.ticker,
|
||||
required this.precision,
|
||||
this.fiatId,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode ^ name.hashCode ^ ticker.hashCode ^ precision.hashCode;
|
||||
int get hashCode =>
|
||||
assetId.hashCode ^ name.hashCode ^ ticker.hashCode ^ precision.hashCode ^ fiatId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -115,7 +125,8 @@ class AssetMetadata {
|
||||
assetId == other.assetId &&
|
||||
name == other.name &&
|
||||
ticker == other.ticker &&
|
||||
precision == other.precision;
|
||||
precision == other.precision &&
|
||||
fiatId == other.fiatId;
|
||||
}
|
||||
|
||||
/// An argument when calling [crate::sdk::LiquidSdk::backup].
|
||||
@@ -291,6 +302,9 @@ class Config {
|
||||
/// By default the asset metadata for Liquid Bitcoin and Tether USD are included.
|
||||
final List<AssetMetadata>? assetMetadata;
|
||||
|
||||
/// The SideSwap API key used for making requests to the SideSwap payjoin service
|
||||
final String? sideswapApiKey;
|
||||
|
||||
const Config({
|
||||
required this.liquidExplorer,
|
||||
required this.bitcoinExplorer,
|
||||
@@ -305,6 +319,7 @@ class Config {
|
||||
required this.useDefaultExternalInputParsers,
|
||||
this.onchainFeeRateLeewaySatPerVbyte,
|
||||
this.assetMetadata,
|
||||
this.sideswapApiKey,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -321,7 +336,8 @@ class Config {
|
||||
externalInputParsers.hashCode ^
|
||||
useDefaultExternalInputParsers.hashCode ^
|
||||
onchainFeeRateLeewaySatPerVbyte.hashCode ^
|
||||
assetMetadata.hashCode;
|
||||
assetMetadata.hashCode ^
|
||||
sideswapApiKey.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -340,7 +356,8 @@ class Config {
|
||||
externalInputParsers == other.externalInputParsers &&
|
||||
useDefaultExternalInputParsers == other.useDefaultExternalInputParsers &&
|
||||
onchainFeeRateLeewaySatPerVbyte == other.onchainFeeRateLeewaySatPerVbyte &&
|
||||
assetMetadata == other.assetMetadata;
|
||||
assetMetadata == other.assetMetadata &&
|
||||
sideswapApiKey == other.sideswapApiKey;
|
||||
}
|
||||
|
||||
/// An argument when calling [crate::sdk::LiquidSdk::connect].
|
||||
@@ -719,7 +736,11 @@ sealed class PayAmount with _$PayAmount {
|
||||
const factory PayAmount.bitcoin({required BigInt receiverAmountSat}) = PayAmount_Bitcoin;
|
||||
|
||||
/// The amount of an asset that will be received
|
||||
const factory PayAmount.asset({required String assetId, required double receiverAmount}) = PayAmount_Asset;
|
||||
const factory PayAmount.asset({
|
||||
required String assetId,
|
||||
required double receiverAmount,
|
||||
bool? estimateAssetFees,
|
||||
}) = PayAmount_Asset;
|
||||
|
||||
/// Indicates that all available Bitcoin funds should be sent
|
||||
const factory PayAmount.drain() = PayAmount_Drain;
|
||||
@@ -1365,12 +1386,20 @@ class PrepareSendRequest {
|
||||
/// Returned when calling [crate::sdk::LiquidSdk::prepare_send_payment].
|
||||
class PrepareSendResponse {
|
||||
final SendDestination destination;
|
||||
final BigInt feesSat;
|
||||
|
||||
const PrepareSendResponse({required this.destination, required this.feesSat});
|
||||
/// The optional estimated fee in satoshi. Is set when there is Bitcoin available
|
||||
/// to pay fees. When not set, there are asset fees available to pay fees.
|
||||
final BigInt? feesSat;
|
||||
|
||||
/// The optional estimated fee in the asset. Is set when [PayAmount::Asset::estimate_asset_fees]
|
||||
/// is set to `true`, the Payjoin service accepts this asset to pay fees and there
|
||||
/// are funds available in this asset to pay fees.
|
||||
final double? estimatedAssetFees;
|
||||
|
||||
const PrepareSendResponse({required this.destination, this.feesSat, this.estimatedAssetFees});
|
||||
|
||||
@override
|
||||
int get hashCode => destination.hashCode ^ feesSat.hashCode;
|
||||
int get hashCode => destination.hashCode ^ feesSat.hashCode ^ estimatedAssetFees.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1378,7 +1407,8 @@ class PrepareSendResponse {
|
||||
other is PrepareSendResponse &&
|
||||
runtimeType == other.runtimeType &&
|
||||
destination == other.destination &&
|
||||
feesSat == other.feesSat;
|
||||
feesSat == other.feesSat &&
|
||||
estimatedAssetFees == other.estimatedAssetFees;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -1616,18 +1646,20 @@ sealed class SendDestination with _$SendDestination {
|
||||
/// An argument when calling [crate::sdk::LiquidSdk::send_payment].
|
||||
class SendPaymentRequest {
|
||||
final PrepareSendResponse prepareResponse;
|
||||
final bool? useAssetFees;
|
||||
|
||||
const SendPaymentRequest({required this.prepareResponse});
|
||||
const SendPaymentRequest({required this.prepareResponse, this.useAssetFees});
|
||||
|
||||
@override
|
||||
int get hashCode => prepareResponse.hashCode;
|
||||
int get hashCode => prepareResponse.hashCode ^ useAssetFees.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SendPaymentRequest &&
|
||||
runtimeType == other.runtimeType &&
|
||||
prepareResponse == other.prepareResponse;
|
||||
prepareResponse == other.prepareResponse &&
|
||||
useAssetFees == other.useAssetFees;
|
||||
}
|
||||
|
||||
/// Returned when calling [crate::sdk::LiquidSdk::send_payment].
|
||||
|
||||
@@ -865,11 +865,12 @@ as BigInt,
|
||||
|
||||
|
||||
class PayAmount_Asset extends PayAmount {
|
||||
const PayAmount_Asset({required this.assetId, required this.receiverAmount}): super._();
|
||||
const PayAmount_Asset({required this.assetId, required this.receiverAmount, this.estimateAssetFees}): super._();
|
||||
|
||||
|
||||
final String assetId;
|
||||
final double receiverAmount;
|
||||
final bool? estimateAssetFees;
|
||||
|
||||
/// Create a copy of PayAmount
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -881,16 +882,16 @@ $PayAmount_AssetCopyWith<PayAmount_Asset> get copyWith => _$PayAmount_AssetCopyW
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PayAmount_Asset&&(identical(other.assetId, assetId) || other.assetId == assetId)&&(identical(other.receiverAmount, receiverAmount) || other.receiverAmount == receiverAmount));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PayAmount_Asset&&(identical(other.assetId, assetId) || other.assetId == assetId)&&(identical(other.receiverAmount, receiverAmount) || other.receiverAmount == receiverAmount)&&(identical(other.estimateAssetFees, estimateAssetFees) || other.estimateAssetFees == estimateAssetFees));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,assetId,receiverAmount);
|
||||
int get hashCode => Object.hash(runtimeType,assetId,receiverAmount,estimateAssetFees);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PayAmount.asset(assetId: $assetId, receiverAmount: $receiverAmount)';
|
||||
return 'PayAmount.asset(assetId: $assetId, receiverAmount: $receiverAmount, estimateAssetFees: $estimateAssetFees)';
|
||||
}
|
||||
|
||||
|
||||
@@ -901,7 +902,7 @@ abstract mixin class $PayAmount_AssetCopyWith<$Res> implements $PayAmountCopyWit
|
||||
factory $PayAmount_AssetCopyWith(PayAmount_Asset value, $Res Function(PayAmount_Asset) _then) = _$PayAmount_AssetCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String assetId, double receiverAmount
|
||||
String assetId, double receiverAmount, bool? estimateAssetFees
|
||||
});
|
||||
|
||||
|
||||
@@ -918,11 +919,12 @@ class _$PayAmount_AssetCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of PayAmount
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? assetId = null,Object? receiverAmount = null,}) {
|
||||
@pragma('vm:prefer-inline') $Res call({Object? assetId = null,Object? receiverAmount = null,Object? estimateAssetFees = freezed,}) {
|
||||
return _then(PayAmount_Asset(
|
||||
assetId: null == assetId ? _self.assetId : assetId // ignore: cast_nullable_to_non_nullable
|
||||
as String,receiverAmount: null == receiverAmount ? _self.receiverAmount : receiverAmount // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
as double,estimateAssetFees: freezed == estimateAssetFees ? _self.estimateAssetFees : estimateAssetFees // ignore: cast_nullable_to_non_nullable
|
||||
as bool?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -4603,6 +4603,8 @@ final class wire_cst_PayAmount_Asset extends ffi.Struct {
|
||||
|
||||
@ffi.Double()
|
||||
external double receiver_amount;
|
||||
|
||||
external ffi.Pointer<ffi.Bool> estimate_asset_fees;
|
||||
}
|
||||
|
||||
final class PayAmountKind extends ffi.Union {
|
||||
@@ -4722,12 +4724,15 @@ final class wire_cst_restore_request extends ffi.Struct {
|
||||
final class wire_cst_prepare_send_response extends ffi.Struct {
|
||||
external wire_cst_send_destination destination;
|
||||
|
||||
@ffi.Uint64()
|
||||
external int fees_sat;
|
||||
external ffi.Pointer<ffi.Uint64> fees_sat;
|
||||
|
||||
external ffi.Pointer<ffi.Double> estimated_asset_fees;
|
||||
}
|
||||
|
||||
final class wire_cst_send_payment_request extends ffi.Struct {
|
||||
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 {
|
||||
@@ -4844,6 +4849,8 @@ final class wire_cst_asset_info extends ffi.Struct {
|
||||
|
||||
@ffi.Double()
|
||||
external double amount;
|
||||
|
||||
external ffi.Pointer<ffi.Double> fees;
|
||||
}
|
||||
|
||||
final class wire_cst_PaymentDetails_Liquid extends ffi.Struct {
|
||||
@@ -5033,6 +5040,8 @@ final class wire_cst_asset_metadata extends ffi.Struct {
|
||||
|
||||
@ffi.Uint8()
|
||||
external int precision;
|
||||
|
||||
external ffi.Pointer<wire_cst_list_prim_u_8_strict> fiat_id;
|
||||
}
|
||||
|
||||
final class wire_cst_list_asset_metadata extends ffi.Struct {
|
||||
@@ -5071,6 +5080,8 @@ final class wire_cst_config extends ffi.Struct {
|
||||
external ffi.Pointer<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_prim_u_8_strict> sideswap_api_key;
|
||||
}
|
||||
|
||||
final class wire_cst_connect_request extends ffi.Struct {
|
||||
@@ -6033,6 +6044,16 @@ const double LIQUID_FEE_RATE_SAT_PER_VBYTE = 0.1;
|
||||
|
||||
const double LIQUID_FEE_RATE_MSAT_PER_VBYTE = 100.0;
|
||||
|
||||
const double MIN_FEE_RATE = 0.1;
|
||||
|
||||
const int WEIGHT_FIXED = 222;
|
||||
|
||||
const int WEIGHT_VIN_SINGLE_SIG_NATIVE = 275;
|
||||
|
||||
const int WEIGHT_VIN_SINGLE_SIG_NESTED = 367;
|
||||
|
||||
const int WEIGHT_VOUT_NESTED = 270;
|
||||
|
||||
const int DEFAULT_ZERO_CONF_MAX_SAT = 1000000;
|
||||
|
||||
const int CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS = 4320;
|
||||
|
||||
@@ -156,7 +156,8 @@ fun asAssetInfo(assetInfo: ReadableMap): AssetInfo? {
|
||||
val name = assetInfo.getString("name")!!
|
||||
val ticker = assetInfo.getString("ticker")!!
|
||||
val amount = assetInfo.getDouble("amount")
|
||||
return AssetInfo(name, ticker, amount)
|
||||
val fees = if (hasNonNullKey(assetInfo, "fees")) assetInfo.getDouble("fees") else null
|
||||
return AssetInfo(name, ticker, amount, fees)
|
||||
}
|
||||
|
||||
fun readableMapOf(assetInfo: AssetInfo): ReadableMap =
|
||||
@@ -164,6 +165,7 @@ fun readableMapOf(assetInfo: AssetInfo): ReadableMap =
|
||||
"name" to assetInfo.name,
|
||||
"ticker" to assetInfo.ticker,
|
||||
"amount" to assetInfo.amount,
|
||||
"fees" to assetInfo.fees,
|
||||
)
|
||||
|
||||
fun asAssetInfoList(arr: ReadableArray): List<AssetInfo> {
|
||||
@@ -194,7 +196,8 @@ fun asAssetMetadata(assetMetadata: ReadableMap): AssetMetadata? {
|
||||
val name = assetMetadata.getString("name")!!
|
||||
val ticker = assetMetadata.getString("ticker")!!
|
||||
val precision = assetMetadata.getInt("precision").toUByte()
|
||||
return AssetMetadata(assetId, name, ticker, precision)
|
||||
val fiatId = if (hasNonNullKey(assetMetadata, "fiatId")) assetMetadata.getString("fiatId") else null
|
||||
return AssetMetadata(assetId, name, ticker, precision, fiatId)
|
||||
}
|
||||
|
||||
fun readableMapOf(assetMetadata: AssetMetadata): ReadableMap =
|
||||
@@ -203,6 +206,7 @@ fun readableMapOf(assetMetadata: AssetMetadata): ReadableMap =
|
||||
"name" to assetMetadata.name,
|
||||
"ticker" to assetMetadata.ticker,
|
||||
"precision" to assetMetadata.precision,
|
||||
"fiatId" to assetMetadata.fiatId,
|
||||
)
|
||||
|
||||
fun asAssetMetadataList(arr: ReadableArray): List<AssetMetadata> {
|
||||
@@ -476,6 +480,7 @@ fun asConfig(config: ReadableMap): Config? {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val sideswapApiKey = if (hasNonNullKey(config, "sideswapApiKey")) config.getString("sideswapApiKey") else null
|
||||
return Config(
|
||||
liquidExplorer,
|
||||
bitcoinExplorer,
|
||||
@@ -490,6 +495,7 @@ fun asConfig(config: ReadableMap): Config? {
|
||||
externalInputParsers,
|
||||
onchainFeeRateLeewaySatPerVbyte,
|
||||
assetMetadata,
|
||||
sideswapApiKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -508,6 +514,7 @@ fun readableMapOf(config: Config): ReadableMap =
|
||||
"externalInputParsers" to config.externalInputParsers?.let { readableArrayOf(it) },
|
||||
"onchainFeeRateLeewaySatPerVbyte" to config.onchainFeeRateLeewaySatPerVbyte,
|
||||
"assetMetadata" to config.assetMetadata?.let { readableArrayOf(it) },
|
||||
"sideswapApiKey" to config.sideswapApiKey,
|
||||
)
|
||||
|
||||
fun asConfigList(arr: ReadableArray): List<Config> {
|
||||
@@ -2281,21 +2288,31 @@ fun asPrepareSendResponse(prepareSendResponse: ReadableMap): PrepareSendResponse
|
||||
prepareSendResponse,
|
||||
arrayOf(
|
||||
"destination",
|
||||
"feesSat",
|
||||
),
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val destination = prepareSendResponse.getMap("destination")?.let { asSendDestination(it) }!!
|
||||
val feesSat = prepareSendResponse.getDouble("feesSat").toULong()
|
||||
return PrepareSendResponse(destination, feesSat)
|
||||
val feesSat = if (hasNonNullKey(prepareSendResponse, "feesSat")) prepareSendResponse.getDouble("feesSat").toULong() else null
|
||||
val estimatedAssetFees =
|
||||
if (hasNonNullKey(
|
||||
prepareSendResponse,
|
||||
"estimatedAssetFees",
|
||||
)
|
||||
) {
|
||||
prepareSendResponse.getDouble("estimatedAssetFees")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return PrepareSendResponse(destination, feesSat, estimatedAssetFees)
|
||||
}
|
||||
|
||||
fun readableMapOf(prepareSendResponse: PrepareSendResponse): ReadableMap =
|
||||
readableMapOf(
|
||||
"destination" to readableMapOf(prepareSendResponse.destination),
|
||||
"feesSat" to prepareSendResponse.feesSat,
|
||||
"estimatedAssetFees" to prepareSendResponse.estimatedAssetFees,
|
||||
)
|
||||
|
||||
fun asPrepareSendResponseList(arr: ReadableArray): List<PrepareSendResponse> {
|
||||
@@ -2684,12 +2701,14 @@ fun asSendPaymentRequest(sendPaymentRequest: ReadableMap): SendPaymentRequest? {
|
||||
return null
|
||||
}
|
||||
val prepareResponse = sendPaymentRequest.getMap("prepareResponse")?.let { asPrepareSendResponse(it) }!!
|
||||
return SendPaymentRequest(prepareResponse)
|
||||
val useAssetFees = if (hasNonNullKey(sendPaymentRequest, "useAssetFees")) sendPaymentRequest.getBoolean("useAssetFees") else null
|
||||
return SendPaymentRequest(prepareResponse, useAssetFees)
|
||||
}
|
||||
|
||||
fun readableMapOf(sendPaymentRequest: SendPaymentRequest): ReadableMap =
|
||||
readableMapOf(
|
||||
"prepareResponse" to readableMapOf(sendPaymentRequest.prepareResponse),
|
||||
"useAssetFees" to sendPaymentRequest.useAssetFees,
|
||||
)
|
||||
|
||||
fun asSendPaymentRequestList(arr: ReadableArray): List<SendPaymentRequest> {
|
||||
@@ -3405,7 +3424,8 @@ fun asPayAmount(payAmount: ReadableMap): PayAmount? {
|
||||
if (type == "asset") {
|
||||
val assetId = payAmount.getString("assetId")!!
|
||||
val receiverAmount = payAmount.getDouble("receiverAmount")
|
||||
return PayAmount.Asset(assetId, receiverAmount)
|
||||
val estimateAssetFees = if (hasNonNullKey(payAmount, "estimateAssetFees")) payAmount.getBoolean("estimateAssetFees") else null
|
||||
return PayAmount.Asset(assetId, receiverAmount, estimateAssetFees)
|
||||
}
|
||||
if (type == "drain") {
|
||||
return PayAmount.Drain
|
||||
@@ -3424,6 +3444,7 @@ fun readableMapOf(payAmount: PayAmount): ReadableMap? {
|
||||
pushToMap(map, "type", "asset")
|
||||
pushToMap(map, "assetId", payAmount.assetId)
|
||||
pushToMap(map, "receiverAmount", payAmount.receiverAmount)
|
||||
pushToMap(map, "estimateAssetFees", payAmount.estimateAssetFees)
|
||||
}
|
||||
is PayAmount.Drain -> {
|
||||
pushToMap(map, "type", "drain")
|
||||
|
||||
@@ -177,8 +177,15 @@ enum BreezSDKLiquidMapper {
|
||||
guard let amount = assetInfo["amount"] as? Double else {
|
||||
throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "amount", typeName: "AssetInfo"))
|
||||
}
|
||||
var fees: Double?
|
||||
if hasNonNilKey(data: assetInfo, key: "fees") {
|
||||
guard let feesTmp = assetInfo["fees"] as? Double else {
|
||||
throw SdkError.Generic(message: errUnexpectedValue(fieldName: "fees"))
|
||||
}
|
||||
fees = feesTmp
|
||||
}
|
||||
|
||||
return AssetInfo(name: name, ticker: ticker, amount: amount)
|
||||
return AssetInfo(name: name, ticker: ticker, amount: amount, fees: fees)
|
||||
}
|
||||
|
||||
static func dictionaryOf(assetInfo: AssetInfo) -> [String: Any?] {
|
||||
@@ -186,6 +193,7 @@ enum BreezSDKLiquidMapper {
|
||||
"name": assetInfo.name,
|
||||
"ticker": assetInfo.ticker,
|
||||
"amount": assetInfo.amount,
|
||||
"fees": assetInfo.fees == nil ? nil : assetInfo.fees,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -219,8 +227,15 @@ enum BreezSDKLiquidMapper {
|
||||
guard let precision = assetMetadata["precision"] as? UInt8 else {
|
||||
throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "precision", typeName: "AssetMetadata"))
|
||||
}
|
||||
var fiatId: String?
|
||||
if hasNonNilKey(data: assetMetadata, key: "fiatId") {
|
||||
guard let fiatIdTmp = assetMetadata["fiatId"] as? String else {
|
||||
throw SdkError.Generic(message: errUnexpectedValue(fieldName: "fiatId"))
|
||||
}
|
||||
fiatId = fiatIdTmp
|
||||
}
|
||||
|
||||
return AssetMetadata(assetId: assetId, name: name, ticker: ticker, precision: precision)
|
||||
return AssetMetadata(assetId: assetId, name: name, ticker: ticker, precision: precision, fiatId: fiatId)
|
||||
}
|
||||
|
||||
static func dictionaryOf(assetMetadata: AssetMetadata) -> [String: Any?] {
|
||||
@@ -229,6 +244,7 @@ enum BreezSDKLiquidMapper {
|
||||
"name": assetMetadata.name,
|
||||
"ticker": assetMetadata.ticker,
|
||||
"precision": assetMetadata.precision,
|
||||
"fiatId": assetMetadata.fiatId == nil ? nil : assetMetadata.fiatId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -561,7 +577,15 @@ enum BreezSDKLiquidMapper {
|
||||
assetMetadata = try asAssetMetadataList(arr: assetMetadataTmp)
|
||||
}
|
||||
|
||||
return Config(liquidExplorer: liquidExplorer, bitcoinExplorer: bitcoinExplorer, workingDir: workingDir, network: network, paymentTimeoutSec: paymentTimeoutSec, syncServiceUrl: syncServiceUrl, breezApiKey: breezApiKey, cacheDir: cacheDir, zeroConfMaxAmountSat: zeroConfMaxAmountSat, useDefaultExternalInputParsers: useDefaultExternalInputParsers, externalInputParsers: externalInputParsers, onchainFeeRateLeewaySatPerVbyte: onchainFeeRateLeewaySatPerVbyte, assetMetadata: assetMetadata)
|
||||
var sideswapApiKey: String?
|
||||
if hasNonNilKey(data: config, key: "sideswapApiKey") {
|
||||
guard let sideswapApiKeyTmp = config["sideswapApiKey"] as? String else {
|
||||
throw SdkError.Generic(message: errUnexpectedValue(fieldName: "sideswapApiKey"))
|
||||
}
|
||||
sideswapApiKey = sideswapApiKeyTmp
|
||||
}
|
||||
|
||||
return Config(liquidExplorer: liquidExplorer, bitcoinExplorer: bitcoinExplorer, workingDir: workingDir, network: network, paymentTimeoutSec: paymentTimeoutSec, syncServiceUrl: syncServiceUrl, breezApiKey: breezApiKey, cacheDir: cacheDir, zeroConfMaxAmountSat: zeroConfMaxAmountSat, useDefaultExternalInputParsers: useDefaultExternalInputParsers, externalInputParsers: externalInputParsers, onchainFeeRateLeewaySatPerVbyte: onchainFeeRateLeewaySatPerVbyte, assetMetadata: assetMetadata, sideswapApiKey: sideswapApiKey)
|
||||
}
|
||||
|
||||
static func dictionaryOf(config: Config) -> [String: Any?] {
|
||||
@@ -579,6 +603,7 @@ enum BreezSDKLiquidMapper {
|
||||
"externalInputParsers": config.externalInputParsers == nil ? nil : arrayOf(externalInputParserList: config.externalInputParsers!),
|
||||
"onchainFeeRateLeewaySatPerVbyte": config.onchainFeeRateLeewaySatPerVbyte == nil ? nil : config.onchainFeeRateLeewaySatPerVbyte,
|
||||
"assetMetadata": config.assetMetadata == nil ? nil : arrayOf(assetMetadataList: config.assetMetadata!),
|
||||
"sideswapApiKey": config.sideswapApiKey == nil ? nil : config.sideswapApiKey,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2652,17 +2677,29 @@ enum BreezSDKLiquidMapper {
|
||||
}
|
||||
let destination = try asSendDestination(sendDestination: destinationTmp)
|
||||
|
||||
guard let feesSat = prepareSendResponse["feesSat"] as? UInt64 else {
|
||||
throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "feesSat", typeName: "PrepareSendResponse"))
|
||||
var feesSat: UInt64?
|
||||
if hasNonNilKey(data: prepareSendResponse, key: "feesSat") {
|
||||
guard let feesSatTmp = prepareSendResponse["feesSat"] as? UInt64 else {
|
||||
throw SdkError.Generic(message: errUnexpectedValue(fieldName: "feesSat"))
|
||||
}
|
||||
feesSat = feesSatTmp
|
||||
}
|
||||
var estimatedAssetFees: Double?
|
||||
if hasNonNilKey(data: prepareSendResponse, key: "estimatedAssetFees") {
|
||||
guard let estimatedAssetFeesTmp = prepareSendResponse["estimatedAssetFees"] as? Double else {
|
||||
throw SdkError.Generic(message: errUnexpectedValue(fieldName: "estimatedAssetFees"))
|
||||
}
|
||||
estimatedAssetFees = estimatedAssetFeesTmp
|
||||
}
|
||||
|
||||
return PrepareSendResponse(destination: destination, feesSat: feesSat)
|
||||
return PrepareSendResponse(destination: destination, feesSat: feesSat, estimatedAssetFees: estimatedAssetFees)
|
||||
}
|
||||
|
||||
static func dictionaryOf(prepareSendResponse: PrepareSendResponse) -> [String: Any?] {
|
||||
return [
|
||||
"destination": dictionaryOf(sendDestination: prepareSendResponse.destination),
|
||||
"feesSat": prepareSendResponse.feesSat,
|
||||
"feesSat": prepareSendResponse.feesSat == nil ? nil : prepareSendResponse.feesSat,
|
||||
"estimatedAssetFees": prepareSendResponse.estimatedAssetFees == nil ? nil : prepareSendResponse.estimatedAssetFees,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3098,12 +3135,21 @@ enum BreezSDKLiquidMapper {
|
||||
}
|
||||
let prepareResponse = try asPrepareSendResponse(prepareSendResponse: prepareResponseTmp)
|
||||
|
||||
return SendPaymentRequest(prepareResponse: prepareResponse)
|
||||
var useAssetFees: Bool?
|
||||
if hasNonNilKey(data: sendPaymentRequest, key: "useAssetFees") {
|
||||
guard let useAssetFeesTmp = sendPaymentRequest["useAssetFees"] as? Bool else {
|
||||
throw SdkError.Generic(message: errUnexpectedValue(fieldName: "useAssetFees"))
|
||||
}
|
||||
useAssetFees = useAssetFeesTmp
|
||||
}
|
||||
|
||||
return SendPaymentRequest(prepareResponse: prepareResponse, useAssetFees: useAssetFees)
|
||||
}
|
||||
|
||||
static func dictionaryOf(sendPaymentRequest: SendPaymentRequest) -> [String: Any?] {
|
||||
return [
|
||||
"prepareResponse": dictionaryOf(prepareSendResponse: sendPaymentRequest.prepareResponse),
|
||||
"useAssetFees": sendPaymentRequest.useAssetFees == nil ? nil : sendPaymentRequest.useAssetFees,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4188,7 +4234,9 @@ enum BreezSDKLiquidMapper {
|
||||
guard let _receiverAmount = payAmount["receiverAmount"] as? Double else {
|
||||
throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "receiverAmount", typeName: "PayAmount"))
|
||||
}
|
||||
return PayAmount.asset(assetId: _assetId, receiverAmount: _receiverAmount)
|
||||
let _estimateAssetFees = payAmount["estimateAssetFees"] as? Bool
|
||||
|
||||
return PayAmount.asset(assetId: _assetId, receiverAmount: _receiverAmount, estimateAssetFees: _estimateAssetFees)
|
||||
}
|
||||
if type == "drain" {
|
||||
return PayAmount.drain
|
||||
@@ -4208,12 +4256,13 @@ enum BreezSDKLiquidMapper {
|
||||
]
|
||||
|
||||
case let .asset(
|
||||
assetId, receiverAmount
|
||||
assetId, receiverAmount, estimateAssetFees
|
||||
):
|
||||
return [
|
||||
"type": "asset",
|
||||
"assetId": assetId,
|
||||
"receiverAmount": receiverAmount,
|
||||
"estimateAssetFees": estimateAssetFees == nil ? nil : estimateAssetFees,
|
||||
]
|
||||
|
||||
case .drain:
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface AssetInfo {
|
||||
name: string
|
||||
ticker: string
|
||||
amount: number
|
||||
fees?: number
|
||||
}
|
||||
|
||||
export interface AssetMetadata {
|
||||
@@ -53,6 +54,7 @@ export interface AssetMetadata {
|
||||
name: string
|
||||
ticker: string
|
||||
precision: number
|
||||
fiatId?: string
|
||||
}
|
||||
|
||||
export interface BackupRequest {
|
||||
@@ -101,6 +103,7 @@ export interface Config {
|
||||
externalInputParsers?: ExternalInputParser[]
|
||||
onchainFeeRateLeewaySatPerVbyte?: number
|
||||
assetMetadata?: AssetMetadata[]
|
||||
sideswapApiKey?: string
|
||||
}
|
||||
|
||||
export interface ConnectRequest {
|
||||
@@ -392,7 +395,8 @@ export interface PrepareSendRequest {
|
||||
|
||||
export interface PrepareSendResponse {
|
||||
destination: SendDestination
|
||||
feesSat: number
|
||||
feesSat?: number
|
||||
estimatedAssetFees?: number
|
||||
}
|
||||
|
||||
export interface Rate {
|
||||
@@ -455,6 +459,7 @@ export interface RouteHintHop {
|
||||
|
||||
export interface SendPaymentRequest {
|
||||
prepareResponse: PrepareSendResponse
|
||||
useAssetFees?: boolean
|
||||
}
|
||||
|
||||
export interface SendPaymentResponse {
|
||||
@@ -682,6 +687,7 @@ export type PayAmount = {
|
||||
type: PayAmountVariant.ASSET,
|
||||
assetId: string
|
||||
receiverAmount: number
|
||||
estimateAssetFees?: boolean
|
||||
} | {
|
||||
type: PayAmountVariant.DRAIN
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user