diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 48ba70156..95eb9032e 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -131,6 +131,8 @@ pub(crate) enum Command { /// Lightning payment hash payment_hash: String, }, + /// Get and potentially accept proposed fees for WaitingFeeAcceptance Payment + ReviewPaymentProposedFees { swap_id: String }, /// List refundable chain swaps ListRefundables, /// Prepare a refund transaction for an incomplete swap @@ -519,6 +521,25 @@ pub(crate) async fn handle_command( } } } + Command::ReviewPaymentProposedFees { swap_id } => { + let fetch_response = sdk + .fetch_payment_proposed_fees(&FetchPaymentProposedFeesRequest { swap_id }) + .await?; + + let confirmation_msg = format!( + "Payer amount: {} sat. Fees: {} sat. Resulting received amount: {} sat. Are the fees acceptable? (y/N) ", + fetch_response.payer_amount_sat, fetch_response.fees_sat, fetch_response.receiver_amount_sat + ); + + wait_confirmation!(confirmation_msg, "Payment proposed fees review halted"); + + sdk.accept_payment_proposed_fees(&AcceptPaymentProposedFeesRequest { + response: fetch_response, + }) + .await?; + + command_result!("Proposed fees accepted successfully") + } Command::ListRefundables => { let refundables = sdk.list_refundables().await?; command_result!(refundables) diff --git a/lib/bindings/langs/android/lib/src/main/kotlin/breez_sdk_liquid_notification/Constants.kt b/lib/bindings/langs/android/lib/src/main/kotlin/breez_sdk_liquid_notification/Constants.kt index 1f4e92421..b125dc3a4 100644 --- a/lib/bindings/langs/android/lib/src/main/kotlin/breez_sdk_liquid_notification/Constants.kt +++ b/lib/bindings/langs/android/lib/src/main/kotlin/breez_sdk_liquid_notification/Constants.kt @@ -57,6 +57,10 @@ object Constants { "payment_sent_notification_text" const val PAYMENT_SENT_NOTIFICATION_TITLE = "payment_sent_notification_title" + const val PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE = + "payment_waiting_fee_acceptance_notification_title" + const val PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT = + "payment_waiting_fee_acceptance_text" const val SWAP_CONFIRMED_NOTIFICATION_FAILURE_TEXT = "swap_confirmed_notification_failure_text" const val SWAP_CONFIRMED_NOTIFICATION_FAILURE_TITLE = @@ -102,6 +106,10 @@ object Constants { "Sent %d sats" const val DEFAULT_PAYMENT_SENT_NOTIFICATION_TITLE = "Payment Sent" + const val DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE = + "Payment requires fee acceptance" + const val DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT = + "Tap to review updated fees" const val DEFAULT_SWAP_CONFIRMED_NOTIFICATION_FAILURE_TEXT = "Tap to complete payment" const val DEFAULT_SWAP_CONFIRMED_NOTIFICATION_FAILURE_TITLE = diff --git a/lib/bindings/langs/android/lib/src/main/kotlin/breez_sdk_liquid_notification/job/SwapUpdated.kt b/lib/bindings/langs/android/lib/src/main/kotlin/breez_sdk_liquid_notification/job/SwapUpdated.kt index f96300be4..532a298c6 100644 --- a/lib/bindings/langs/android/lib/src/main/kotlin/breez_sdk_liquid_notification/job/SwapUpdated.kt +++ b/lib/bindings/langs/android/lib/src/main/kotlin/breez_sdk_liquid_notification/job/SwapUpdated.kt @@ -10,6 +10,8 @@ import breez_sdk_liquid_notification.Constants.DEFAULT_PAYMENT_RECEIVED_NOTIFICA import breez_sdk_liquid_notification.Constants.DEFAULT_PAYMENT_RECEIVED_NOTIFICATION_TITLE import breez_sdk_liquid_notification.Constants.DEFAULT_PAYMENT_SENT_NOTIFICATION_TEXT import breez_sdk_liquid_notification.Constants.DEFAULT_PAYMENT_SENT_NOTIFICATION_TITLE +import breez_sdk_liquid_notification.Constants.DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT +import breez_sdk_liquid_notification.Constants.DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE import breez_sdk_liquid_notification.Constants.DEFAULT_SWAP_CONFIRMED_NOTIFICATION_FAILURE_TEXT import breez_sdk_liquid_notification.Constants.DEFAULT_SWAP_CONFIRMED_NOTIFICATION_FAILURE_TITLE import breez_sdk_liquid_notification.Constants.NOTIFICATION_CHANNEL_SWAP_UPDATED @@ -17,6 +19,8 @@ import breez_sdk_liquid_notification.Constants.PAYMENT_RECEIVED_NOTIFICATION_TEX import breez_sdk_liquid_notification.Constants.PAYMENT_RECEIVED_NOTIFICATION_TITLE import breez_sdk_liquid_notification.Constants.PAYMENT_SENT_NOTIFICATION_TEXT import breez_sdk_liquid_notification.Constants.PAYMENT_SENT_NOTIFICATION_TITLE +import breez_sdk_liquid_notification.Constants.PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT +import breez_sdk_liquid_notification.Constants.PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE import breez_sdk_liquid_notification.Constants.SWAP_CONFIRMED_NOTIFICATION_FAILURE_TEXT import breez_sdk_liquid_notification.Constants.SWAP_CONFIRMED_NOTIFICATION_FAILURE_TITLE import breez_sdk_liquid_notification.NotificationHelper.Companion.notifyChannel @@ -57,8 +61,9 @@ class SwapUpdatedJob( override fun onEvent(e: SdkEvent) { when (e) { - is SdkEvent.PaymentWaitingConfirmation -> handlePaymentEvent(e.details) - is SdkEvent.PaymentSucceeded -> handlePaymentEvent(e.details) + is SdkEvent.PaymentWaitingConfirmation -> handlePaymentSuccess(e.details) + is SdkEvent.PaymentSucceeded -> handlePaymentSuccess(e.details) + is SdkEvent.PaymentWaitingFeeAcceptance -> handlePaymentWaitingFeeAcceptance(e.details) else -> { logger.log(TAG, "Received event: ${e}", "TRACE") @@ -76,12 +81,16 @@ class SwapUpdatedJob( .fold(StringBuilder()) { sb, it -> sb.append("%02x".format(it)) } .toString() - private fun handlePaymentEvent(payment: Payment) { - val swapId = when (val details = payment.details) { + private fun getSwapId(details: PaymentDetails?): String? { + return when (details) { is PaymentDetails.Bitcoin -> details.swapId is PaymentDetails.Lightning -> details.swapId else -> null } + } + + private fun handlePaymentSuccess(payment: Payment) { + val swapId = getSwapId(payment.details) swapId?.let { if (this.swapIdHash == hashId(it)) { @@ -95,6 +104,21 @@ class SwapUpdatedJob( } } + private fun handlePaymentWaitingFeeAcceptance(payment: Payment) { + val swapId = getSwapId(payment.details) + + swapId?.let { + if (this.swapIdHash == hashId(it)) { + logger.log( + TAG, + "Payment waiting fee acceptance: ${this.swapIdHash}", + "TRACE" + ) + notifyPaymentWaitingFeeAcceptance(payment) + } + } + } + private fun notifySuccess(payment: Payment) { if (!this.notified) { logger.log(TAG, "Payment ${payment.txId} processing successful", "INFO") @@ -121,6 +145,28 @@ class SwapUpdatedJob( } } + private fun notifyPaymentWaitingFeeAcceptance(payment: Payment) { + if (!this.notified) { + logger.log(TAG, "Payment with swap ID ${getSwapId(payment.details)} requires fee acceptance", "INFO") + notifyChannel( + context, + NOTIFICATION_CHANNEL_SWAP_UPDATED, + getString( + context, + PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE, + DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE + ), + getString( + context, + PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT, + DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT + ) + ) + this.notified = true + fgService.onFinished(this) + } + } + private fun notifyFailure() { this.swapIdHash?.let { swapIdHash -> logger.log(TAG, "Swap $swapIdHash processing failed", "INFO") diff --git a/lib/bindings/langs/flutter/breez_sdk_liquid/include/breez_sdk_liquid.h b/lib/bindings/langs/flutter/breez_sdk_liquid/include/breez_sdk_liquid.h index b882180ef..5a6eafade 100644 --- a/lib/bindings/langs/flutter/breez_sdk_liquid/include/breez_sdk_liquid.h +++ b/lib/bindings/langs/flutter/breez_sdk_liquid/include/breez_sdk_liquid.h @@ -16,6 +16,8 @@ typedef struct _Dart_Handle* Dart_Handle; #define ESTIMATED_BTC_CLAIM_TX_VSIZE 111 +#define ESTIMATED_BTC_LOCKUP_TX_VSIZE 154 + #define STANDARD_FEE_RATE_SAT_PER_VBYTE 0.1 #define LOWBALL_FEE_RATE_SAT_PER_VBYTE 0.01 @@ -541,6 +543,10 @@ typedef struct wire_cst_SdkEvent_PaymentWaitingConfirmation { struct wire_cst_payment *details; } wire_cst_SdkEvent_PaymentWaitingConfirmation; +typedef struct wire_cst_SdkEvent_PaymentWaitingFeeAcceptance { + struct wire_cst_payment *details; +} wire_cst_SdkEvent_PaymentWaitingFeeAcceptance; + typedef union SdkEventKind { struct wire_cst_SdkEvent_PaymentFailed PaymentFailed; struct wire_cst_SdkEvent_PaymentPending PaymentPending; @@ -548,6 +554,7 @@ typedef union SdkEventKind { struct wire_cst_SdkEvent_PaymentRefundPending PaymentRefundPending; struct wire_cst_SdkEvent_PaymentSucceeded PaymentSucceeded; struct wire_cst_SdkEvent_PaymentWaitingConfirmation PaymentWaitingConfirmation; + struct wire_cst_SdkEvent_PaymentWaitingFeeAcceptance PaymentWaitingFeeAcceptance; } SdkEventKind; typedef struct wire_cst_sdk_event { @@ -580,6 +587,7 @@ typedef struct wire_cst_config { struct wire_cst_list_prim_u_8_strict *breez_api_key; struct wire_cst_list_external_input_parser *external_input_parsers; bool use_default_external_input_parsers; + uint32_t *onchain_fee_rate_leeway_sat_per_vbyte; } wire_cst_config; typedef struct wire_cst_connect_request { diff --git a/lib/bindings/langs/flutter/breez_sdk_liquidFFI/include/breez_sdk_liquidFFI.h b/lib/bindings/langs/flutter/breez_sdk_liquidFFI/include/breez_sdk_liquidFFI.h index 0f02f4e88..61bb48c5c 100644 --- a/lib/bindings/langs/flutter/breez_sdk_liquidFFI/include/breez_sdk_liquidFFI.h +++ b/lib/bindings/langs/flutter/breez_sdk_liquidFFI/include/breez_sdk_liquidFFI.h @@ -65,6 +65,8 @@ typedef void (*UniFfiRustFutureContinuation)(void * _Nonnull, int8_t); // Scaffolding functions void uniffi_breez_sdk_liquid_bindings_fn_free_bindingliquidsdk(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); +void uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_accept_payment_proposed_fees(void*_Nonnull ptr, RustBuffer req, RustCallStatus *_Nonnull out_status +); RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_add_event_listener(void*_Nonnull ptr, uint64_t listener, RustCallStatus *_Nonnull out_status ); void uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_backup(void*_Nonnull ptr, RustBuffer req, RustCallStatus *_Nonnull out_status @@ -81,6 +83,8 @@ RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_lig ); RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_onchain_limits(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); +RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_payment_proposed_fees(void*_Nonnull ptr, RustBuffer req, RustCallStatus *_Nonnull out_status +); RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_get_info(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_get_payment(void*_Nonnull ptr, RustBuffer req, RustCallStatus *_Nonnull out_status @@ -279,6 +283,9 @@ uint16_t uniffi_breez_sdk_liquid_bindings_checksum_func_parse_invoice(void ); uint16_t uniffi_breez_sdk_liquid_bindings_checksum_func_set_logger(void +); +uint16_t uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_accept_payment_proposed_fees(void + ); uint16_t uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_add_event_listener(void @@ -303,6 +310,9 @@ uint16_t uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch ); uint16_t uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_onchain_limits(void +); +uint16_t uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_payment_proposed_fees(void + ); uint16_t uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_get_info(void diff --git a/lib/bindings/langs/swift/Sources/BreezSDKLiquid/Constants.swift b/lib/bindings/langs/swift/Sources/BreezSDKLiquid/Constants.swift index aaef8dc11..5e26b545a 100644 --- a/lib/bindings/langs/swift/Sources/BreezSDKLiquid/Constants.swift +++ b/lib/bindings/langs/swift/Sources/BreezSDKLiquid/Constants.swift @@ -20,6 +20,8 @@ struct Constants { static let LNURL_PAY_NOTIFICATION_FAILURE_TITLE = "lnurl_pay_notification_failure_title" static let PAYMENT_RECEIVED_NOTIFICATION_TITLE = "payment_received_notification_title" static let PAYMENT_SENT_NOTIFICATION_TITLE = "payment_sent_notification_title" + static let PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE = "payment_waiting_fee_acceptance_notification_title" + static let PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT = "payment_waiting_fee_acceptance_text" static let SWAP_CONFIRMED_NOTIFICATION_FAILURE_TEXT = "swap_confirmed_notification_failure_text" static let SWAP_CONFIRMED_NOTIFICATION_FAILURE_TITLE = "swap_confirmed_notification_failure_title" @@ -30,6 +32,8 @@ struct Constants { static let DEFAULT_LNURL_PAY_NOTIFICATION_FAILURE_TITLE = "Receive Payment Failed" static let DEFAULT_PAYMENT_RECEIVED_NOTIFICATION_TITLE = "Received %d sats" static let DEFAULT_PAYMENT_SENT_NOTIFICATION_TITLE = "Sent %d sats" + static let DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE = "Payment requires fee acceptance" + static let DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT = "Tap to review updated fees" static let DEFAULT_SWAP_CONFIRMED_NOTIFICATION_FAILURE_TEXT = "Tap to complete payment" static let DEFAULT_SWAP_CONFIRMED_NOTIFICATION_FAILURE_TITLE = "Payment Pending" } diff --git a/lib/bindings/langs/swift/Sources/BreezSDKLiquid/Task/SwapUpdated.swift b/lib/bindings/langs/swift/Sources/BreezSDKLiquid/Task/SwapUpdated.swift index fd70f715b..26a74d2e2 100644 --- a/lib/bindings/langs/swift/Sources/BreezSDKLiquid/Task/SwapUpdated.swift +++ b/lib/bindings/langs/swift/Sources/BreezSDKLiquid/Task/SwapUpdated.swift @@ -43,6 +43,13 @@ class SwapUpdatedTask : TaskProtocol { self.notifySuccess(payment: payment) } break + case .paymentWaitingFeeAcceptance(details: let payment): + let swapId = self.getSwapId(details: payment.details) + if swapIdHash == swapId?.sha256() { + self.logger.log(tag: TAG, line: "Received payment event: \(swapIdHash) \(payment.status)", level: "INFO") + self.notifyPaymentWaitingFeeAcceptance(payment: payment) + } + break default: break } @@ -81,4 +88,18 @@ class SwapUpdatedTask : TaskProtocol { self.displayPushNotification(title: String(format: notificationTitle, payment.amountSat), logger: self.logger, threadIdentifier: Constants.NOTIFICATION_THREAD_SWAP_UPDATED) } } + + func notifyPaymentWaitingFeeAcceptance(payment: Payment) { + if !self.notified { + self.logger.log(tag: TAG, line: "Payment \(self.getSwapId(details: payment.details) ?? "") requires fee acceptance", level: "INFO") + let notificationTitle = ResourceHelper.shared.getString( + key: Constants.PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE, + fallback: Constants.DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TITLE) + let notificationBody = ResourceHelper.shared.getString( + key: Constants.PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT, + fallback: Constants.DEFAULT_PAYMENT_WAITING_FEE_ACCEPTANCE_TEXT) + self.notified = true + self.displayPushNotification(title: notificationTitle, body: notificationBody, logger: self.logger, threadIdentifier: Constants.NOTIFICATION_THREAD_SWAP_UPDATED) + } + } } diff --git a/lib/bindings/src/breez_sdk_liquid.udl b/lib/bindings/src/breez_sdk_liquid.udl index 89626f05a..6a63085eb 100644 --- a/lib/bindings/src/breez_sdk_liquid.udl +++ b/lib/bindings/src/breez_sdk_liquid.udl @@ -338,6 +338,7 @@ dictionary Config { u64? zero_conf_max_amount_sat; boolean use_default_external_input_parsers = true; sequence? external_input_parsers = null; + u32? onchain_fee_rate_leeway_sat_per_vbyte = null; }; enum LiquidNetwork { @@ -541,6 +542,21 @@ interface GetPaymentRequest { Lightning(string payment_hash); }; +dictionary FetchPaymentProposedFeesRequest { + string swap_id; +}; + +dictionary FetchPaymentProposedFeesResponse { + string swap_id; + u64 fees_sat; + u64 payer_amount_sat; + u64 receiver_amount_sat; +}; + +dictionary AcceptPaymentProposedFeesRequest { + FetchPaymentProposedFeesResponse response; +}; + dictionary LnUrlInfo { string? ln_address; string? lnurl_pay_comment; @@ -584,6 +600,7 @@ enum PaymentState { "TimedOut", "Refundable", "RefundPending", + "WaitingFeeAcceptance", }; dictionary RefundableSwap { @@ -630,6 +647,7 @@ interface SdkEvent { PaymentRefundPending(Payment details); PaymentSucceeded(Payment details); PaymentWaitingConfirmation(Payment details); + PaymentWaitingFeeAcceptance(Payment details); Synced(); }; @@ -755,6 +773,12 @@ interface BindingLiquidSdk { [Throws=PaymentError] Payment? get_payment(GetPaymentRequest req); + [Throws=SdkError] + FetchPaymentProposedFeesResponse fetch_payment_proposed_fees(FetchPaymentProposedFeesRequest req); + + [Throws=PaymentError] + void accept_payment_proposed_fees(AcceptPaymentProposedFeesRequest req); + [Throws=SdkError] sequence list_refundables(); diff --git a/lib/bindings/src/lib.rs b/lib/bindings/src/lib.rs index 995fc3b1f..6845f8eea 100644 --- a/lib/bindings/src/lib.rs +++ b/lib/bindings/src/lib.rs @@ -170,6 +170,20 @@ impl BindingLiquidSdk { rt().block_on(self.sdk.get_payment(&req)) } + pub fn fetch_payment_proposed_fees( + &self, + req: FetchPaymentProposedFeesRequest, + ) -> SdkResult { + rt().block_on(self.sdk.fetch_payment_proposed_fees(&req)) + } + + pub fn accept_payment_proposed_fees( + &self, + req: AcceptPaymentProposedFeesRequest, + ) -> Result<(), PaymentError> { + rt().block_on(self.sdk.accept_payment_proposed_fees(&req)) + } + pub fn prepare_lnurl_pay( &self, req: PrepareLnUrlPayRequest, diff --git a/lib/core/src/chain_swap.rs b/lib/core/src/chain_swap.rs index 94ef25a39..d0b8a4829 100644 --- a/lib/core/src/chain_swap.rs +++ b/lib/core/src/chain_swap.rs @@ -32,8 +32,9 @@ use crate::{ wallet::OnchainWallet, }; -// Estimates based on https://github.com/BoltzExchange/boltz-backend/blob/ee4c77be1fcb9bb2b45703c542ad67f7efbf218d/lib/rates/FeeProvider.ts#L78 +// Estimates based on https://github.com/BoltzExchange/boltz-backend/blob/ee4c77be1fcb9bb2b45703c542ad67f7efbf218d/lib/rates/FeeProvider.ts#L68 pub const ESTIMATED_BTC_CLAIM_TX_VSIZE: u64 = 111; +pub const ESTIMATED_BTC_LOCKUP_TX_VSIZE: u64 = 154; pub(crate) struct ChainSwapHandler { config: Config, @@ -332,7 +333,7 @@ impl ChainSwapHandler { { match self.handle_amountless_update(swap).await { Ok(_) => { - // We successfully accepted the quote, the swap should continue as normal + // Either we accepted the quote, or we will be waiting for user fee acceptance return Ok(()); // Break from TxLockupFailed branch } // In case of error, we continue and mark it as refundable @@ -377,23 +378,59 @@ impl ChainSwapHandler { } async fn handle_amountless_update(&self, swap: &ChainSwap) -> Result<(), PaymentError> { + let id = swap.id.clone(); + + // Since we optimistically persist the accepted receiver amount, if accepting a quote with + // the swapper fails, we might still think it's accepted, so now we should get rid of the + // old invalid accepted amount. + if swap.accepted_receiver_amount_sat.is_some() { + info!("Handling amountless update for swap {id} with existing accepted receiver amount. Erasing the accepted amount now..."); + self.persister.update_accepted_receiver_amount(&id, None)?; + } + let quote = self .swapper - .get_zero_amount_chain_swap_quote(&swap.id) + .get_zero_amount_chain_swap_quote(&id) .map(|quote| quote.to_sat())?; - info!("Got quote of {quote} sat for swap {}", &swap.id); - - self.validate_and_update_amountless_swap(swap, quote) - .await?; - self.swapper - .accept_zero_amount_chain_swap_quote(&swap.id, quote) + info!("Got quote of {quote} sat for swap {}", &id); + + match self.validate_amountless_swap(swap, quote).await? { + ValidateAmountlessSwapResult::ReadyForAccepting { + user_lockup_amount_sat, + receiver_amount_sat, + } => { + debug!("Zero-amount swap validated. Auto-accepting..."); + self.persister + .update_actual_payer_amount(&id, user_lockup_amount_sat)?; + self.persister + .update_accepted_receiver_amount(&id, Some(receiver_amount_sat))?; + self.swapper + .accept_zero_amount_chain_swap_quote(&id, quote) + .inspect_err(|e| { + error!("Failed to accept zero-amount swap {id} quote: {e} - trying to erase the accepted receiver amount..."); + let _ = self.persister.update_accepted_receiver_amount(&id, None); + }) + } + ValidateAmountlessSwapResult::RequiresUserAction { + user_lockup_amount_sat, + } => { + debug!("Zero-amount swap validated. Fees are too high for automatic accepting. Moving to WaitingFeeAcceptance"); + self.persister + .update_actual_payer_amount(&id, user_lockup_amount_sat)?; + self.update_swap_info(&ChainSwapUpdate { + swap_id: id, + to_state: WaitingFeeAcceptance, + ..Default::default() + }) + } + } } - async fn validate_and_update_amountless_swap( + async fn validate_amountless_swap( &self, swap: &ChainSwap, quote_server_lockup_amount_sat: u64, - ) -> Result<(), PaymentError> { + ) -> Result { debug!("Validating {swap:?}"); ensure_sdk!( @@ -425,32 +462,42 @@ impl ChainSwapHandler { ); let pair = swap.get_boltz_pair()?; - let swapper_service_feerate = pair.fees.percentage; - let swapper_server_fees_sat = pair.fees.server(); - let service_fees_sat = - ((swapper_service_feerate / 100.0) * user_lockup_amount_sat as f64).ceil() as u64; - let fees_sat = swapper_server_fees_sat + service_fees_sat; - ensure_sdk!( - user_lockup_amount_sat > fees_sat, - PaymentError::generic(&format!("Invalid quote: fees ({fees_sat} sat) are higher than user lockup ({user_lockup_amount_sat} sat)")) - ); - let expected_server_lockup_amount_sat = user_lockup_amount_sat - fees_sat; - debug!("user_lockup_amount_sat = {}, service_fees_sat = {}, server_fees_sat = {}, expected_server_lockup_amount_sat = {}, quote_server_lockup_amount_sat = {}", - user_lockup_amount_sat, service_fees_sat, swapper_server_fees_sat, expected_server_lockup_amount_sat, quote_server_lockup_amount_sat); - ensure_sdk!( - expected_server_lockup_amount_sat <= quote_server_lockup_amount_sat, - PaymentError::generic(&format!("Invalid quote: expected at least {expected_server_lockup_amount_sat} sat, got {quote_server_lockup_amount_sat} sat")) - ); + // Original server lockup quote estimate + let server_fees_estimate_sat = pair.fees.server(); + let service_fees_sat = pair.fees.boltz(user_lockup_amount_sat); + let server_lockup_amount_estimate_sat = + user_lockup_amount_sat - server_fees_estimate_sat - service_fees_sat; + + // Min auto accept server lockup quote + let server_fees_leeway_sat = self + .config + .onchain_fee_rate_leeway_sat_per_vbyte + .unwrap_or(0) as u64 + * ESTIMATED_BTC_LOCKUP_TX_VSIZE; + let min_auto_accept_server_lockup_amount_sat = + server_lockup_amount_estimate_sat.saturating_sub(server_fees_leeway_sat); - let receiver_amount_sat = quote_server_lockup_amount_sat - swap.claim_fees_sat; - self.persister.update_zero_amount_swap_values( - &swap.id, - user_lockup_amount_sat, - receiver_amount_sat, - )?; + debug!( + "user_lockup_amount_sat = {user_lockup_amount_sat}, \ + service_fees_sat = {service_fees_sat}, \ + server_fees_estimate_sat = {server_fees_estimate_sat}, \ + server_fees_leeway_sat = {server_fees_leeway_sat}, \ + min_auto_accept_server_lockup_amount_sat = {min_auto_accept_server_lockup_amount_sat}, \ + quote_server_lockup_amount_sat = {quote_server_lockup_amount_sat}", + ); - Ok(()) + if min_auto_accept_server_lockup_amount_sat > quote_server_lockup_amount_sat { + Ok(ValidateAmountlessSwapResult::RequiresUserAction { + user_lockup_amount_sat, + }) + } else { + let receiver_amount_sat = quote_server_lockup_amount_sat - swap.claim_fees_sat; + Ok(ValidateAmountlessSwapResult::ReadyForAccepting { + user_lockup_amount_sat, + receiver_amount_sat, + }) + } } async fn on_new_outgoing_status(&self, swap: &ChainSwap, update: &boltz::Update) -> Result<()> { @@ -902,9 +949,9 @@ impl ChainSwapHandler { let id = &swap.id; ensure_sdk!( - swap.state == Refundable, + swap.state.is_refundable(), PaymentError::Generic { - err: format!("Chain Swap {id} was not marked as `Refundable`") + err: format!("Chain Swap {id} was not in refundable state") } ); @@ -1077,12 +1124,17 @@ impl ChainSwapHandler { err: "Cannot transition to Created state".to_string(), }), - (Created | Pending, Pending) => Ok(()), + (Created | Pending | WaitingFeeAcceptance, Pending) => Ok(()), (_, Pending) => Err(PaymentError::Generic { err: format!("Cannot transition from {from_state:?} to Pending state"), }), - (Created | Pending | RefundPending, Complete) => Ok(()), + (Created | Pending | WaitingFeeAcceptance, WaitingFeeAcceptance) => Ok(()), + (_, WaitingFeeAcceptance) => Err(PaymentError::Generic { + err: format!("Cannot transition from {from_state:?} to WaitingFeeAcceptance state"), + }), + + (Created | Pending | WaitingFeeAcceptance | RefundPending, Complete) => Ok(()), (_, Complete) => Err(PaymentError::Generic { err: format!("Cannot transition from {from_state:?} to Complete state"), }), @@ -1092,12 +1144,15 @@ impl ChainSwapHandler { err: format!("Cannot transition from {from_state:?} to TimedOut state"), }), - (Created | Pending | RefundPending | Failed | Complete, Refundable) => Ok(()), + ( + Created | Pending | WaitingFeeAcceptance | RefundPending | Failed | Complete, + Refundable, + ) => Ok(()), (_, Refundable) => Err(PaymentError::Generic { err: format!("Cannot transition from {from_state:?} to Refundable state"), }), - (Pending | Refundable, RefundPending) => Ok(()), + (Pending | WaitingFeeAcceptance | Refundable, RefundPending) => Ok(()), (_, RefundPending) => Err(PaymentError::Generic { err: format!("Cannot transition from {from_state:?} to RefundPending state"), }), @@ -1177,12 +1232,27 @@ impl ChainSwapHandler { .unblind(&secp, liquid_swap_script.blinding_key.secret_key())? .value; } - if value < claim_details.amount { - return Err(anyhow!( - "Transaction value {value} sats is less than {} sats", - claim_details.amount - )); + match chain_swap.accepted_receiver_amount_sat { + None => { + if value < claim_details.amount { + return Err(anyhow!( + "Transaction value {value} sats is less than {} sats", + claim_details.amount + )); + } + } + Some(accepted_receiver_amount_sat) => { + let expected_server_lockup_amount_sat = + accepted_receiver_amount_sat + chain_swap.claim_fees_sat; + if value < expected_server_lockup_amount_sat { + return Err(anyhow!( + "Transaction value {value} sats is less than accepted {} sats", + expected_server_lockup_amount_sat + )); + } + } } + Ok(()) } @@ -1299,6 +1369,16 @@ impl ChainSwapHandler { } } +enum ValidateAmountlessSwapResult { + ReadyForAccepting { + user_lockup_amount_sat: u64, + receiver_amount_sat: u64, + }, + RequiresUserAction { + user_lockup_amount_sat: u64, + }, +} + #[cfg(test)] mod tests { use anyhow::Result; @@ -1322,15 +1402,47 @@ mod tests { let chain_swap_handler = new_chain_swap_handler(persister.clone())?; // Test valid combinations of states - let all_states = HashSet::from([Created, Pending, Complete, TimedOut, Failed]); + let all_states = HashSet::from([ + Created, + Pending, + WaitingFeeAcceptance, + Complete, + TimedOut, + Failed, + ]); let valid_combinations = HashMap::from([ ( Created, - HashSet::from([Pending, Complete, TimedOut, Refundable, Failed]), + HashSet::from([ + Pending, + WaitingFeeAcceptance, + Complete, + TimedOut, + Refundable, + Failed, + ]), ), ( Pending, - HashSet::from([Pending, Complete, Refundable, RefundPending, Failed]), + HashSet::from([ + Pending, + WaitingFeeAcceptance, + Complete, + Refundable, + RefundPending, + Failed, + ]), + ), + ( + WaitingFeeAcceptance, + HashSet::from([ + Pending, + WaitingFeeAcceptance, + Complete, + Refundable, + RefundPending, + Failed, + ]), ), (TimedOut, HashSet::from([Failed])), (Complete, HashSet::from([Refundable])), @@ -1342,7 +1454,7 @@ mod tests { for (first_state, allowed_states) in valid_combinations.iter() { for allowed_state in allowed_states { let chain_swap = - new_chain_swap(Direction::Incoming, Some(*first_state), false, None); + new_chain_swap(Direction::Incoming, Some(*first_state), false, None, false); persister.insert_or_update_chain_swap(&chain_swap)?; assert!(chain_swap_handler @@ -1369,7 +1481,7 @@ mod tests { for (first_state, disallowed_states) in invalid_combinations.iter() { for disallowed_state in disallowed_states { let chain_swap = - new_chain_swap(Direction::Incoming, Some(*first_state), false, None); + new_chain_swap(Direction::Incoming, Some(*first_state), false, None, false); persister.insert_or_update_chain_swap(&chain_swap)?; assert!(chain_swap_handler diff --git a/lib/core/src/frb_generated.rs b/lib/core/src/frb_generated.rs index ec5cd90a1..0f6799c8a 100644 --- a/lib/core/src/frb_generated.rs +++ b/lib/core/src/frb_generated.rs @@ -2092,6 +2092,7 @@ impl CstDecode for i32 { 4 => crate::model::PaymentState::TimedOut, 5 => crate::model::PaymentState::Refundable, 6 => crate::model::PaymentState::RefundPending, + 7 => crate::model::PaymentState::WaitingFeeAcceptance, _ => unreachable!("Invalid variant for PaymentState: {}", self), } } @@ -2378,6 +2379,7 @@ impl SseDecode for crate::model::Config { let mut var_externalInputParsers = >>::sse_decode(deserializer); let mut var_useDefaultExternalInputParsers = ::sse_decode(deserializer); + let mut var_onchainFeeRateLeewaySatPerVbyte = >::sse_decode(deserializer); return crate::model::Config { liquid_electrum_url: var_liquidElectrumUrl, bitcoin_electrum_url: var_bitcoinElectrumUrl, @@ -2392,6 +2394,7 @@ impl SseDecode for crate::model::Config { breez_api_key: var_breezApiKey, external_input_parsers: var_externalInputParsers, use_default_external_input_parsers: var_useDefaultExternalInputParsers, + onchain_fee_rate_leeway_sat_per_vbyte: var_onchainFeeRateLeewaySatPerVbyte, }; } } @@ -3781,6 +3784,7 @@ impl SseDecode for crate::model::PaymentState { 4 => crate::model::PaymentState::TimedOut, 5 => crate::model::PaymentState::Refundable, 6 => crate::model::PaymentState::RefundPending, + 7 => crate::model::PaymentState::WaitingFeeAcceptance, _ => unreachable!("Invalid variant for PaymentState: {}", inner), }; } @@ -4170,6 +4174,12 @@ impl SseDecode for crate::model::SdkEvent { }; } 6 => { + let mut var_details = ::sse_decode(deserializer); + return crate::model::SdkEvent::PaymentWaitingFeeAcceptance { + details: var_details, + }; + } + 7 => { return crate::model::SdkEvent::Synced; } _ => { @@ -4677,6 +4687,9 @@ impl flutter_rust_bridge::IntoDart for crate::model::Config { self.use_default_external_input_parsers .into_into_dart() .into_dart(), + self.onchain_fee_rate_leeway_sat_per_vbyte + .into_into_dart() + .into_dart(), ] .into_dart() } @@ -5836,6 +5849,7 @@ impl flutter_rust_bridge::IntoDart for crate::model::PaymentState { Self::TimedOut => 4.into_dart(), Self::Refundable => 5.into_dart(), Self::RefundPending => 6.into_dart(), + Self::WaitingFeeAcceptance => 7.into_dart(), _ => unreachable!(), } } @@ -6368,7 +6382,10 @@ impl flutter_rust_bridge::IntoDart for crate::model::SdkEvent { crate::model::SdkEvent::PaymentWaitingConfirmation { details } => { [5.into_dart(), details.into_into_dart().into_dart()].into_dart() } - crate::model::SdkEvent::Synced => [6.into_dart()].into_dart(), + crate::model::SdkEvent::PaymentWaitingFeeAcceptance { details } => { + [6.into_dart(), details.into_into_dart().into_dart()].into_dart() + } + crate::model::SdkEvent::Synced => [7.into_dart()].into_dart(), _ => { unimplemented!(""); } @@ -6787,6 +6804,7 @@ impl SseEncode for crate::model::Config { serializer, ); ::sse_encode(self.use_default_external_input_parsers, serializer); + >::sse_encode(self.onchain_fee_rate_leeway_sat_per_vbyte, serializer); } } @@ -7907,6 +7925,7 @@ impl SseEncode for crate::model::PaymentState { crate::model::PaymentState::TimedOut => 4, crate::model::PaymentState::Refundable => 5, crate::model::PaymentState::RefundPending => 6, + crate::model::PaymentState::WaitingFeeAcceptance => 7, _ => { unimplemented!(""); } @@ -8181,8 +8200,12 @@ impl SseEncode for crate::model::SdkEvent { ::sse_encode(5, serializer); ::sse_encode(details, serializer); } - crate::model::SdkEvent::Synced => { + crate::model::SdkEvent::PaymentWaitingFeeAcceptance { details } => { ::sse_encode(6, serializer); + ::sse_encode(details, serializer); + } + crate::model::SdkEvent::Synced => { + ::sse_encode(7, serializer); } _ => { unimplemented!(""); @@ -8947,6 +8970,9 @@ mod io { use_default_external_input_parsers: self .use_default_external_input_parsers .cst_decode(), + onchain_fee_rate_leeway_sat_per_vbyte: self + .onchain_fee_rate_leeway_sat_per_vbyte + .cst_decode(), } } } @@ -10152,7 +10178,13 @@ mod io { details: ans.details.cst_decode(), } } - 6 => crate::model::SdkEvent::Synced, + 6 => { + let ans = unsafe { self.kind.PaymentWaitingFeeAcceptance }; + crate::model::SdkEvent::PaymentWaitingFeeAcceptance { + details: ans.details.cst_decode(), + } + } + 7 => crate::model::SdkEvent::Synced, _ => unreachable!(), } } @@ -10437,6 +10469,7 @@ mod io { breez_api_key: core::ptr::null_mut(), external_input_parsers: core::ptr::null_mut(), use_default_external_input_parsers: Default::default(), + onchain_fee_rate_leeway_sat_per_vbyte: core::ptr::null_mut(), } } } @@ -12517,6 +12550,7 @@ mod io { breez_api_key: *mut wire_cst_list_prim_u_8_strict, external_input_parsers: *mut wire_cst_list_external_input_parser, use_default_external_input_parsers: bool, + onchain_fee_rate_leeway_sat_per_vbyte: *mut u32, } #[repr(C)] #[derive(Clone, Copy)] @@ -13492,6 +13526,7 @@ mod io { PaymentRefundPending: wire_cst_SdkEvent_PaymentRefundPending, PaymentSucceeded: wire_cst_SdkEvent_PaymentSucceeded, PaymentWaitingConfirmation: wire_cst_SdkEvent_PaymentWaitingConfirmation, + PaymentWaitingFeeAcceptance: wire_cst_SdkEvent_PaymentWaitingFeeAcceptance, nil__: (), } #[repr(C)] @@ -13526,6 +13561,11 @@ mod io { } #[repr(C)] #[derive(Clone, Copy)] + pub struct wire_cst_SdkEvent_PaymentWaitingFeeAcceptance { + details: *mut wire_cst_payment, + } + #[repr(C)] + #[derive(Clone, Copy)] pub struct wire_cst_send_destination { tag: i32, kind: SendDestinationKind, diff --git a/lib/core/src/model.rs b/lib/core/src/model.rs index c6deced95..1d0f55b84 100644 --- a/lib/core/src/model.rs +++ b/lib/core/src/model.rs @@ -1,3 +1,4 @@ +use std::cmp::PartialEq; use std::path::PathBuf; use anyhow::{anyhow, Result}; @@ -65,6 +66,13 @@ pub struct Config { /// ([DEFAULT_EXTERNAL_INPUT_PARSERS](crate::sdk::DEFAULT_EXTERNAL_INPUT_PARSERS)). /// Set this to false in order to prevent their use. pub use_default_external_input_parsers: bool, + /// For payments where the onchain fees can only be estimated on creation, this can be used + /// in order to automatically allow slightly more expensive fees. If the actual fee rate ends up + /// being above the sum of the initial estimate and this leeway, the payment will require + /// user fee acceptance. See [WaitingFeeAcceptance](PaymentState::WaitingFeeAcceptance). + /// + /// Defaults to zero. + pub onchain_fee_rate_leeway_sat_per_vbyte: Option, } impl Config { @@ -83,6 +91,7 @@ impl Config { breez_api_key: Some(breez_api_key), external_input_parsers: None, use_default_external_input_parsers: true, + onchain_fee_rate_leeway_sat_per_vbyte: None, } } @@ -101,6 +110,7 @@ impl Config { breez_api_key, external_input_parsers: None, use_default_external_input_parsers: true, + onchain_fee_rate_leeway_sat_per_vbyte: None, } } @@ -243,6 +253,7 @@ pub enum SdkEvent { PaymentRefundPending { details: Payment }, PaymentSucceeded { details: Payment }, PaymentWaitingConfirmation { details: Payment }, + PaymentWaitingFeeAcceptance { details: Payment }, Synced, } @@ -718,8 +729,15 @@ pub(crate) struct ChainSwap { pub(crate) timeout_block_height: u32, pub(crate) preimage: String, pub(crate) description: Option, + /// Payer amount defined at swap creation pub(crate) payer_amount_sat: u64, + /// The actual payer amount as seen on the user lockup tx. Might differ from `payer_amount_sat` + /// in the case of an over/underpayment + pub(crate) actual_payer_amount_sat: Option, + /// Receiver amount defined at swap creation pub(crate) receiver_amount_sat: u64, + /// The final receiver amount, in case of an over/underpayment that has been accepted + pub(crate) accepted_receiver_amount_sat: Option, pub(crate) claim_fees_sat: u64, /// The [ChainPair] chosen on swap creation pub(crate) pair_fees_json: String, @@ -844,6 +862,17 @@ impl ChainSwap { Ok(create_response_json) } + + pub(crate) fn is_amount_mismatch(&self) -> bool { + match self.actual_payer_amount_sat { + Some(actual_amount) => actual_amount != self.payer_amount_sat, + None => false, + } + } + + pub(crate) fn is_waiting_fee_acceptance(&self) -> bool { + self.is_amount_mismatch() && self.accepted_receiver_amount_sat.is_none() + } } #[derive(Clone, Debug, Default)] @@ -1121,6 +1150,19 @@ pub enum PaymentState { /// /// When the refund tx is broadcast, `refund_tx_id` is set in the swap. RefundPending = 6, + + /// ## Chain Swaps + /// + /// This is the state when the user needs to accept new fees before the payment can proceed. + /// + /// Use [LiquidSdk::fetch_payment_proposed_fees](crate::sdk::LiquidSdk::fetch_payment_proposed_fees) + /// to find out the current fees and + /// [LiquidSdk::accept_payment_proposed_fees](crate::sdk::LiquidSdk::accept_payment_proposed_fees) + /// to accept them, allowing the payment to proceed. + /// + /// Otherwise, this payment can be immediately refunded using + /// [prepare_refund](crate::sdk::LiquidSdk::prepare_refund)/[refund](crate::sdk::LiquidSdk::refund). + WaitingFeeAcceptance = 7, } impl ToSql for PaymentState { fn to_sql(&self) -> rusqlite::Result> { @@ -1138,6 +1180,7 @@ impl FromSql for PaymentState { 4 => Ok(PaymentState::TimedOut), 5 => Ok(PaymentState::Refundable), 6 => Ok(PaymentState::RefundPending), + 7 => Ok(PaymentState::WaitingFeeAcceptance), _ => Err(FromSqlError::OutOfRange(i)), }, _ => Err(FromSqlError::InvalidType), @@ -1145,6 +1188,15 @@ impl FromSql for PaymentState { } } +impl PaymentState { + pub(crate) fn is_refundable(&self) -> bool { + matches!( + self, + PaymentState::Refundable | PaymentState::WaitingFeeAcceptance + ) + } +} + #[derive(Debug, Copy, Clone, Eq, EnumString, Display, Hash, PartialEq, Serialize)] #[strum(serialize_all = "lowercase")] pub enum PaymentType { @@ -1486,7 +1538,7 @@ impl Payment { .unwrap_or(utils::now()), amount_sat: tx.amount_sat, fees_sat: match swap.as_ref() { - Some(s) => s.payer_amount_sat - s.receiver_amount_sat, + Some(s) => s.payer_amount_sat - tx.amount_sat, None => match tx.payment_type { PaymentType::Receive => 0, PaymentType::Send => tx.fees_sat, @@ -1731,6 +1783,29 @@ impl Utxo { } } +/// An argument when calling [crate::sdk::LiquidSdk::fetch_payment_proposed_fees]. +#[derive(Debug, Clone)] +pub struct FetchPaymentProposedFeesRequest { + pub swap_id: String, +} + +/// Returned when calling [crate::sdk::LiquidSdk::fetch_payment_proposed_fees]. +#[derive(Debug, Clone, Serialize)] +pub struct FetchPaymentProposedFeesResponse { + pub swap_id: String, + pub fees_sat: u64, + /// Amount sent by the swap payer + pub payer_amount_sat: u64, + /// Amount that will be received if these fees are accepted + pub receiver_amount_sat: u64, +} + +/// An argument when calling [crate::sdk::LiquidSdk::accept_payment_proposed_fees]. +#[derive(Debug, Clone)] +pub struct AcceptPaymentProposedFeesRequest { + pub response: FetchPaymentProposedFeesResponse, +} + #[macro_export] macro_rules! get_invoice_amount { ($invoice:expr) => { diff --git a/lib/core/src/persist/chain.rs b/lib/core/src/persist/chain.rs index 03ecfd19d..529c9fe8a 100644 --- a/lib/core/src/persist/chain.rs +++ b/lib/core/src/persist/chain.rs @@ -70,7 +70,9 @@ impl Persister { claim_tx_id = :claim_tx_id, refund_tx_id = :refund_tx_id, pair_fees_json = :pair_fees_json, - state = :state + state = :state, + actual_payer_amount_sat = :actual_payer_amount_sat, + accepted_receiver_amount_sat = COALESCE(accepted_receiver_amount_sat, :accepted_receiver_amount_sat) WHERE id = :id", named_params! { @@ -84,6 +86,8 @@ impl Persister { ":refund_tx_id": &chain_swap.refund_tx_id, ":pair_fees_json": &chain_swap.pair_fees_json, ":state": &chain_swap.state, + ":actual_payer_amount_sat": &chain_swap.actual_payer_amount_sat, + ":accepted_receiver_amount_sat": &chain_swap.accepted_receiver_amount_sat, }, )?; @@ -147,7 +151,9 @@ impl Persister { refund_tx_id, created_at, state, - pair_fees_json + pair_fees_json, + actual_payer_amount_sat, + accepted_receiver_amount_sat FROM chain_swaps {where_clause_str} ORDER BY created_at @@ -197,6 +203,8 @@ impl Persister { created_at: row.get(18)?, state: row.get(19)?, pair_fees_json: row.get(20)?, + actual_payer_amount_sat: row.get(21)?, + accepted_receiver_amount_sat: row.get(22)?, }) } @@ -233,13 +241,18 @@ impl Persister { let where_clause = vec![get_where_clause_state_in(&[ PaymentState::Created, PaymentState::Pending, + PaymentState::WaitingFeeAcceptance, ])]; self.list_chain_swaps_where(&con, where_clause) } pub(crate) fn list_pending_chain_swaps(&self) -> Result> { - self.list_chain_swaps_by_state(vec![PaymentState::Pending, PaymentState::RefundPending]) + self.list_chain_swaps_by_state(vec![ + PaymentState::Pending, + PaymentState::RefundPending, + PaymentState::WaitingFeeAcceptance, + ]) } pub(crate) fn list_refundable_chain_swaps(&self) -> Result> { @@ -281,39 +294,57 @@ impl Persister { Ok(()) } - /// Used for Zero-amount Receive Chain swaps, when we fetched the quote and we know how much - /// the sender locked up - pub(crate) fn update_zero_amount_swap_values( + /// Used for receive chain swaps, when the sender over/underpays + pub(crate) fn update_actual_payer_amount( &self, swap_id: &str, - payer_amount_sat: u64, - receiver_amount_sat: u64, + actual_payer_amount_sat: u64, ) -> Result<(), PaymentError> { - log::info!("Updating chain swap {swap_id}: payer_amount_sat = {payer_amount_sat}, receiver_amount_sat = {receiver_amount_sat}"); + log::info!( + "Updating chain swap {swap_id}: actual_payer_amount_sat = {actual_payer_amount_sat}" + ); + let con: Connection = self.get_connection()?; + con.execute( + "UPDATE chain_swaps + SET actual_payer_amount_sat = :actual_payer_amount_sat + WHERE id = :id", + named_params! { + ":id": swap_id, + ":actual_payer_amount_sat": actual_payer_amount_sat, + }, + )?; + + Ok(()) + } + + /// Used for receive chain swaps, when fees are accepted and thus the agreed received amount is known + /// + /// Can also be used to erase a previously persisted accepted amount in case of failure to accept. + pub(crate) fn update_accepted_receiver_amount( + &self, + swap_id: &str, + accepted_receiver_amount_sat: Option, + ) -> Result<(), PaymentError> { + log::info!( + "Updating chain swap {swap_id}: accepted_receiver_amount_sat = {accepted_receiver_amount_sat:?}" + ); let mut con: Connection = self.get_connection()?; let tx = con.transaction_with_behavior(TransactionBehavior::Immediate)?; tx.execute( - "UPDATE chain_swaps - SET - payer_amount_sat = :payer_amount_sat, - receiver_amount_sat = :receiver_amount_sat - WHERE - id = :id", + "UPDATE chain_swaps + SET accepted_receiver_amount_sat = :accepted_receiver_amount_sat + WHERE id = :id", named_params! { ":id": swap_id, - ":payer_amount_sat": payer_amount_sat, - ":receiver_amount_sat": receiver_amount_sat, + ":accepted_receiver_amount_sat": accepted_receiver_amount_sat, }, )?; self.commit_outgoing( &tx, swap_id, RecordType::Chain, - Some(vec![ - "payer_amount_sat".to_string(), - "receiver_amount_sat".to_string(), - ]), + Some(vec!["accepted_receiver_amount_sat".to_string()]), )?; tx.commit()?; self.sync_trigger diff --git a/lib/core/src/persist/migrations.rs b/lib/core/src/persist/migrations.rs index 965b983d5..87a772d29 100644 --- a/lib/core/src/persist/migrations.rs +++ b/lib/core/src/persist/migrations.rs @@ -215,5 +215,7 @@ pub(crate) fn current_migrations() -> Vec<&'static str> { "ALTER TABLE receive_swaps DROP COLUMN mrh_script_pubkey;", "ALTER TABLE payment_details ADD COLUMN lnurl_info_json TEXT;", "ALTER TABLE payment_tx_data ADD COLUMN unblinding_data TEXT;", + "ALTER TABLE chain_swaps ADD COLUMN actual_payer_amount_sat INTEGER;", + "ALTER TABLE chain_swaps ADD COLUMN accepted_receiver_amount_sat INTEGER;", ] } diff --git a/lib/core/src/persist/mod.rs b/lib/core/src/persist/mod.rs index c9998dbd0..15cee000a 100644 --- a/lib/core/src/persist/mod.rs +++ b/lib/core/src/persist/mod.rs @@ -400,6 +400,8 @@ impl Persister { cs.claim_address, cs.state, cs.pair_fees_json, + cs.actual_payer_amount_sat, + cs.accepted_receiver_amount_sat, rtx.amount_sat, pd.destination, pd.description, @@ -497,12 +499,14 @@ impl Persister { let maybe_chain_swap_pair_fees_json: Option = row.get(39)?; let maybe_chain_swap_pair_fees: Option = maybe_chain_swap_pair_fees_json.and_then(|pair| serde_json::from_str(&pair).ok()); + let maybe_chain_swap_actual_payer_amount_sat: Option = row.get(40)?; + let maybe_chain_swap_accepted_receiver_amount_sat: Option = row.get(41)?; - let maybe_swap_refund_tx_amount_sat: Option = row.get(40)?; + let maybe_swap_refund_tx_amount_sat: Option = row.get(42)?; - let maybe_payment_details_destination: Option = row.get(41)?; - let maybe_payment_details_description: Option = row.get(42)?; - let maybe_payment_details_lnurl_info_json: Option = row.get(43)?; + let maybe_payment_details_destination: Option = row.get(43)?; + let maybe_payment_details_description: Option = row.get(44)?; + let maybe_payment_details_lnurl_info_json: Option = row.get(45)?; let maybe_payment_details_lnurl_info: Option = maybe_payment_details_lnurl_info_json.and_then(|info| serde_json::from_str(&info).ok()); @@ -569,7 +573,21 @@ impl Persister { } None => match maybe_chain_swap_id { Some(chain_swap_id) => { - let payer_amount_sat = maybe_chain_swap_payer_amount_sat.unwrap_or(0); + let payer_amount_sat = match maybe_chain_swap_actual_payer_amount_sat { + Some(actual_payer_amount_sat) => actual_payer_amount_sat, + None => maybe_chain_swap_payer_amount_sat.unwrap_or(0), + }; + let receiver_amount_sat = + match maybe_chain_swap_accepted_receiver_amount_sat { + Some(accepted_receiver_amount_sat) => accepted_receiver_amount_sat, + None => match ( + maybe_chain_swap_actual_payer_amount_sat, + maybe_chain_swap_payer_amount_sat, + ) { + (Some(actual), Some(expected)) if actual != expected => actual, // For over/underpaid chain swaps WaitingFeeAcceptance, show zero fees + _ => maybe_chain_swap_receiver_amount_sat.unwrap_or(0), + }, + }; let swapper_fees_sat = maybe_chain_swap_pair_fees .map(|pair| pair.fees.percentage) .map(|fr| ((fr / 100.0) * payer_amount_sat as f64).ceil() as u64) @@ -587,8 +605,7 @@ impl Persister { description: maybe_chain_swap_description .unwrap_or("Bitcoin transfer".to_string()), payer_amount_sat, - receiver_amount_sat: maybe_chain_swap_receiver_amount_sat - .unwrap_or(0), + receiver_amount_sat, swapper_fees_sat, refund_tx_id: maybe_chain_swap_refund_tx_id, refund_tx_amount_sat: maybe_swap_refund_tx_amount_sat, diff --git a/lib/core/src/receive_swap.rs b/lib/core/src/receive_swap.rs index 420126b99..186582149 100644 --- a/lib/core/src/receive_swap.rs +++ b/lib/core/src/receive_swap.rs @@ -405,6 +405,10 @@ impl ReceiveSwapHandler { err: format!("Cannot transition from {from_state:?} to Failed state"), }), (_, Failed) => Ok(()), + + (_, WaitingFeeAcceptance) => Err(PaymentError::Generic { + err: format!("Cannot transition from {from_state:?} to WaitingFeeAcceptance state"), + }), } } diff --git a/lib/core/src/recover/model.rs b/lib/core/src/recover/model.rs index bec768e4a..32f77f495 100644 --- a/lib/core/src/recover/model.rs +++ b/lib/core/src/recover/model.rs @@ -188,7 +188,9 @@ pub(crate) struct RecoveredOnchainDataChainReceive { pub(crate) lbtc_claim_address: Option, /// BTC tx initiated by the payer (the "user" as per Boltz), sending funds to the swap funding address. pub(crate) btc_user_lockup_tx_id: Option, - /// BTC total funds available at the swap funding address. + /// BTC total funds currently available at the swap funding address. + pub(crate) btc_user_lockup_address_balance_sat: u64, + /// BTC sent to lockup address as part of lockup tx. pub(crate) btc_user_lockup_amount_sat: u64, /// BTC tx initiated by the SDK to a user-chosen address, in case the initial funds have to be refunded. pub(crate) btc_refund_tx_id: Option, @@ -199,9 +201,10 @@ impl RecoveredOnchainDataChainReceive { &self, min_lockup_amount_sat: u64, is_expired: bool, + is_waiting_fee_acceptance: bool, ) -> Option { - let is_refundable = self.btc_user_lockup_amount_sat > 0 - && (is_expired || self.btc_user_lockup_amount_sat < min_lockup_amount_sat); + let is_refundable = self.btc_user_lockup_address_balance_sat > 0 + && (is_expired || self.btc_user_lockup_amount_sat < min_lockup_amount_sat); // TODO: this does not support accepting over/underpayments match &self.btc_user_lockup_tx_id { Some(_) => match (&self.lbtc_claim_tx_id, &self.btc_refund_tx_id) { (Some(lbtc_claim_tx_id), None) => match lbtc_claim_tx_id.confirmed() { @@ -232,7 +235,10 @@ impl RecoveredOnchainDataChainReceive { } (None, None) => match is_refundable { true => Some(PaymentState::Refundable), - false => Some(PaymentState::Pending), + false => match is_waiting_fee_acceptance { + true => Some(PaymentState::WaitingFeeAcceptance), + false => Some(PaymentState::Pending), + }, }, }, None => match is_expired { diff --git a/lib/core/src/recover/recoverer.rs b/lib/core/src/recover/recoverer.rs index 9efc3c681..bde28e1c4 100644 --- a/lib/core/src/recover/recoverer.rs +++ b/lib/core/src/recover/recoverer.rs @@ -12,6 +12,7 @@ use lwk_wollet::hashes::{sha256, Hash as _}; use lwk_wollet::WalletTx; use tokio::sync::Mutex; +use super::model::*; use crate::prelude::{Direction, Swap}; use crate::wallet::OnchainWallet; use crate::{ @@ -19,8 +20,6 @@ use crate::{ recover::model::{BtcScript, HistoryTxId, LBtcScript}, }; -use super::model::*; - pub(crate) struct Recoverer { master_blinding_key: MasterBlindingKey, onchain_wallet: Arc, @@ -213,11 +212,15 @@ impl Recoverer { log::warn!("Could not apply recovered data for incoming Chain swap {swap_id}: recovery data not found"); continue; }; + chain_swap.actual_payer_amount_sat = + Some(recovered_data.btc_user_lockup_amount_sat); let is_expired = bitcoin_height >= chain_swap.timeout_block_height; let min_lockup_amount_sat = chain_swap.payer_amount_sat; - if let Some(new_state) = - recovered_data.derive_partial_state(min_lockup_amount_sat, is_expired) - { + if let Some(new_state) = recovered_data.derive_partial_state( + min_lockup_amount_sat, + is_expired, + chain_swap.is_waiting_fee_acceptance(), + ) { chain_swap.state = new_state; } chain_swap.server_lockup_tx_id = recovered_data @@ -654,7 +657,7 @@ impl Recoverer { }; // Get the current confirmed amount available for the lockup script - let btc_user_lockup_amount_sat = history + let btc_user_lockup_address_balance_sat = history .btc_lockup_script_balance .map(|balance| balance.confirmed) .unwrap_or_default(); @@ -686,6 +689,16 @@ impl Recoverer { .find(|h| h.txid.as_raw_hash() == tx.txid().as_raw_hash()) }) .cloned(); + let btc_user_lockup_amount_sat = btc_lockup_incoming_txs + .first() + .and_then(|tx| { + tx.output + .iter() + .find(|out| out.script_pubkey == btc_lockup_script) + .map(|out| out.value) + }) + .unwrap_or_default() + .to_sat(); let btc_outgoing_tx_ids: Vec = btc_lockup_outgoing_txs .iter() .filter_map(|tx| { @@ -723,6 +736,7 @@ impl Recoverer { lbtc_claim_tx_id, lbtc_claim_address, btc_user_lockup_tx_id, + btc_user_lockup_address_balance_sat, btc_user_lockup_amount_sat, btc_refund_tx_id, }, diff --git a/lib/core/src/sdk.rs b/lib/core/src/sdk.rs index 5399301c4..283eae3c7 100644 --- a/lib/core/src/sdk.rs +++ b/lib/core/src/sdk.rs @@ -3,7 +3,7 @@ use std::ops::Not as _; use std::time::Instant; use std::{fs, path::PathBuf, str::FromStr, sync::Arc, time::Duration}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, ensure, Result}; use boltz_client::{swaps::boltz::*, util::secrets::Preimage}; use buy::{BuyBitcoinApi, BuyBitcoinService}; use chain::bitcoin::HybridBitcoinChainService; @@ -560,6 +560,25 @@ impl LiquidSdk { } }; } + WaitingFeeAcceptance => { + let swap_id = &payment + .details + .get_swap_id() + .ok_or(anyhow!("Payment WaitingFeeAcceptance must have a swap"))?; + + ensure!( + matches!( + self.persister.fetch_swap_by_id(swap_id)?, + Swap::Chain(ChainSwap { .. }) + ), + "Swap in WaitingFeeAcceptance payment must be chain swap" + ); + + self.notify_event_listeners(SdkEvent::PaymentWaitingFeeAcceptance { + details: payment, + }) + .await?; + } RefundPending => { // The swap state has changed to RefundPending self.notify_event_listeners(SdkEvent::PaymentRefundPending { @@ -1298,6 +1317,12 @@ impl LiquidSdk { "Payment has already failed. Please try with another invoice", )) } + WaitingFeeAcceptance => { + return Err(PaymentError::Generic { + err: "Send swap payment cannot be in state WaitingFeeAcceptance" + .to_string(), + }) + } }, None => { let keypair = utils::generate_keypair(); @@ -1619,7 +1644,9 @@ impl LiquidSdk { preimage: preimage_str, description: Some("Bitcoin transfer".to_string()), payer_amount_sat, + actual_payer_amount_sat: None, receiver_amount_sat, + accepted_receiver_amount_sat: None, claim_fees_sat, pair_fees_json: serde_json::to_string(&pair).map_err(|e| { PaymentError::generic(&format!("Failed to serialize outgoing ChainPair: {e:?}")) @@ -2061,7 +2088,9 @@ impl LiquidSdk { preimage: preimage_str, description: Some("Bitcoin transfer".to_string()), payer_amount_sat: user_lockup_amount_sat.unwrap_or(0), + actual_payer_amount_sat: None, receiver_amount_sat, + accepted_receiver_amount_sat: None, claim_fees_sat, pair_fees_json: serde_json::to_string(&pair).map_err(|e| { PaymentError::generic(&format!("Failed to serialize incoming ChainPair: {e:?}")) @@ -2487,7 +2516,11 @@ impl LiquidSdk { let mut pending_send_sat = 0; let mut pending_receive_sat = 0; let payments = self.persister.get_payments(&ListPaymentsRequest { - states: Some(vec![PaymentState::Pending, PaymentState::RefundPending]), + states: Some(vec![ + PaymentState::Pending, + PaymentState::RefundPending, + PaymentState::WaitingFeeAcceptance, + ]), ..Default::default() })?; @@ -2541,6 +2574,102 @@ impl LiquidSdk { Ok(self.persister.get_payment_by_request(req)?) } + /// Fetches an up-to-date fees proposal for a [Payment] that is [WaitingFeeAcceptance]. + /// + /// Use [LiquidSdk::accept_payment_proposed_fees] to accept the proposed fees and proceed + /// with the payment. + pub async fn fetch_payment_proposed_fees( + &self, + req: &FetchPaymentProposedFeesRequest, + ) -> SdkResult { + let chain_swap = + self.persister + .fetch_chain_swap_by_id(&req.swap_id)? + .ok_or(SdkError::Generic { + err: format!("Could not find Swap {}", req.swap_id), + })?; + + ensure_sdk!( + chain_swap.state == WaitingFeeAcceptance, + SdkError::Generic { + err: "Payment is not WaitingFeeAcceptance".to_string() + } + ); + + let server_lockup_quote = self + .swapper + .get_zero_amount_chain_swap_quote(&req.swap_id)?; + + let actual_payer_amount_sat = + chain_swap + .actual_payer_amount_sat + .ok_or(SdkError::Generic { + err: "No actual payer amount found when state is WaitingFeeAcceptance" + .to_string(), + })?; + let fees_sat = + actual_payer_amount_sat - server_lockup_quote.to_sat() + chain_swap.claim_fees_sat; + + Ok(FetchPaymentProposedFeesResponse { + swap_id: req.swap_id.clone(), + fees_sat, + payer_amount_sat: actual_payer_amount_sat, + receiver_amount_sat: actual_payer_amount_sat - fees_sat, + }) + } + + /// Accepts proposed fees for a [Payment] that is [WaitingFeeAcceptance]. + /// + /// Use [LiquidSdk::fetch_payment_proposed_fees] to get an up-to-date fees proposal. + pub async fn accept_payment_proposed_fees( + &self, + req: &AcceptPaymentProposedFeesRequest, + ) -> Result<(), PaymentError> { + let FetchPaymentProposedFeesResponse { + swap_id, + fees_sat, + payer_amount_sat, + .. + } = req.clone().response; + + let chain_swap = + self.persister + .fetch_chain_swap_by_id(&swap_id)? + .ok_or(SdkError::Generic { + err: format!("Could not find Swap {}", swap_id), + })?; + + ensure_sdk!( + chain_swap.state == WaitingFeeAcceptance, + PaymentError::Generic { + err: "Payment is not WaitingFeeAcceptance".to_string() + } + ); + + let server_lockup_quote = self.swapper.get_zero_amount_chain_swap_quote(&swap_id)?; + + ensure_sdk!( + fees_sat == payer_amount_sat - server_lockup_quote.to_sat() + chain_swap.claim_fees_sat, + PaymentError::InvalidOrExpiredFees + ); + + self.persister + .update_accepted_receiver_amount(&swap_id, Some(payer_amount_sat - fees_sat))?; + self.swapper + .accept_zero_amount_chain_swap_quote(&swap_id, server_lockup_quote.to_sat()) + .inspect_err(|e| { + error!("Failed to accept zero-amount swap {swap_id} quote: {e} - trying to erase the accepted receiver amount..."); + let _ = self + .persister + .update_accepted_receiver_amount(&swap_id, None); + })?; + self.chain_swap_handler.update_swap_info(&ChainSwapUpdate { + swap_id, + to_state: Pending, + ..Default::default() + }) + } + /// Empties the Liquid Wallet cache for the [Config::network]. pub fn empty_wallet_cache(&self) -> Result<()> { let mut path = PathBuf::from(self.config.working_dir.clone()); @@ -3029,6 +3158,8 @@ mod tests { use lwk_wollet::{elements::Txid, hashes::hex::DisplayHex}; use tokio::sync::Mutex; + use crate::chain_swap::ESTIMATED_BTC_LOCKUP_TX_VSIZE; + use crate::test_utils::swapper::ZeroAmountSwapMockConfig; use crate::{ model::{Direction, PaymentState, Swap}, sdk::LiquidSdk, @@ -3049,6 +3180,7 @@ mod tests { accepts_zero_conf: bool, initial_payment_state: Option, user_lockup_tx_id: Option, + zero_amount: bool, } impl Default for NewSwapArgs { @@ -3058,6 +3190,7 @@ mod tests { initial_payment_state: None, direction: Direction::Outgoing, user_lockup_tx_id: None, + zero_amount: false, } } } @@ -3082,6 +3215,11 @@ mod tests { self.initial_payment_state = Some(payment_state); self } + + pub fn set_zero_amount(mut self, zero_amount: bool) -> Self { + self.zero_amount = zero_amount; + self + } } macro_rules! trigger_swap_update { @@ -3101,6 +3239,7 @@ mod tests { $args.initial_payment_state, $args.accepts_zero_conf, $args.user_lockup_tx_id, + $args.zero_amount, ); $persister.insert_or_update_chain_swap(&swap).unwrap(); Swap::Chain(swap) @@ -3274,6 +3413,7 @@ mod tests { status_stream.clone(), liquid_chain_service.clone(), bitcoin_chain_service.clone(), + None, )?); LiquidSdk::track_swap_updates(&sdk).await; @@ -3463,4 +3603,137 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_zero_amount_chain_swap_zero_leeway() -> Result<()> { + let user_lockup_sat = 50_000; + + create_persister!(persister); + let swapper = Arc::new(MockSwapper::new()); + let status_stream = Arc::new(MockStatusStream::new()); + let liquid_chain_service = Arc::new(Mutex::new(MockLiquidChainService::new())); + let bitcoin_chain_service = Arc::new(Mutex::new(MockBitcoinChainService::new())); + + let sdk = Arc::new(new_liquid_sdk_with_chain_services( + persister.clone(), + swapper.clone(), + status_stream.clone(), + liquid_chain_service.clone(), + bitcoin_chain_service.clone(), + None, + )?); + + LiquidSdk::track_swap_updates(&sdk).await; + + // We spawn a new thread since updates can only be sent when called via async runtimes + tokio::spawn(async move { + // Verify that `TransactionLockupFailed` correctly: + // 1. does not affect state when swapper doesn't increase fees + // 2. triggers a change to WaitingFeeAcceptance when there is a fee increase > 0 + for fee_increase in [0, 1] { + swapper.set_zero_amount_swap_mock_config(ZeroAmountSwapMockConfig { + user_lockup_sat, + onchain_fee_increase_sat: fee_increase, + }); + bitcoin_chain_service + .lock() + .await + .set_script_balance_sat(user_lockup_sat); + let persisted_swap = trigger_swap_update!( + "chain", + NewSwapArgs::default() + .set_direction(Direction::Incoming) + .set_accepts_zero_conf(false) + .set_zero_amount(true), + persister, + status_stream, + ChainSwapStates::TransactionLockupFailed, + None, + None + ); + match fee_increase { + 0 => { + assert_eq!(persisted_swap.state, PaymentState::Created); + } + 1 => { + assert_eq!(persisted_swap.state, PaymentState::WaitingFeeAcceptance); + } + _ => panic!("Unexpected fee_increase"), + } + } + }) + .await?; + + Ok(()) + } + + #[tokio::test] + async fn test_zero_amount_chain_swap_with_leeway() -> Result<()> { + let user_lockup_sat = 50_000; + let onchain_fee_rate_leeway_sat_per_vbyte = 5; + + create_persister!(persister); + let swapper = Arc::new(MockSwapper::new()); + let status_stream = Arc::new(MockStatusStream::new()); + let liquid_chain_service = Arc::new(Mutex::new(MockLiquidChainService::new())); + let bitcoin_chain_service = Arc::new(Mutex::new(MockBitcoinChainService::new())); + + let sdk = Arc::new(new_liquid_sdk_with_chain_services( + persister.clone(), + swapper.clone(), + status_stream.clone(), + liquid_chain_service.clone(), + bitcoin_chain_service.clone(), + Some(onchain_fee_rate_leeway_sat_per_vbyte), + )?); + + LiquidSdk::track_swap_updates(&sdk).await; + + let max_fee_increase_for_auto_accept_sat = + onchain_fee_rate_leeway_sat_per_vbyte as u64 * ESTIMATED_BTC_LOCKUP_TX_VSIZE; + + // We spawn a new thread since updates can only be sent when called via async runtimes + tokio::spawn(async move { + // Verify that `TransactionLockupFailed` correctly: + // 1. does not affect state when swapper increases fee by up to sat/vbyte leeway * tx size + // 2. triggers a change to WaitingFeeAcceptance when it is any higher + for fee_increase in [ + max_fee_increase_for_auto_accept_sat, + max_fee_increase_for_auto_accept_sat + 1, + ] { + swapper.set_zero_amount_swap_mock_config(ZeroAmountSwapMockConfig { + user_lockup_sat, + onchain_fee_increase_sat: fee_increase, + }); + bitcoin_chain_service + .lock() + .await + .set_script_balance_sat(user_lockup_sat); + let persisted_swap = trigger_swap_update!( + "chain", + NewSwapArgs::default() + .set_direction(Direction::Incoming) + .set_accepts_zero_conf(false) + .set_zero_amount(true), + persister, + status_stream, + ChainSwapStates::TransactionLockupFailed, + None, + None + ); + match fee_increase { + val if val == max_fee_increase_for_auto_accept_sat => { + assert_eq!(persisted_swap.state, PaymentState::Created); + } + val if val == (max_fee_increase_for_auto_accept_sat + 1) => { + assert_eq!(persisted_swap.state, PaymentState::WaitingFeeAcceptance); + } + _ => panic!("Unexpected fee_increase"), + } + } + }) + .await?; + + Ok(()) + } } diff --git a/lib/core/src/send_swap.rs b/lib/core/src/send_swap.rs index 338da576b..248afe298 100644 --- a/lib/core/src/send_swap.rs +++ b/lib/core/src/send_swap.rs @@ -592,6 +592,10 @@ impl SendSwapHandler { err: format!("Cannot transition from {from_state:?} to Failed state"), }), (_, Failed) => Ok(()), + + (_, WaitingFeeAcceptance) => Err(PaymentError::Generic { + err: format!("Cannot transition from {from_state:?} to WaitingFeeAcceptance state"), + }), } } diff --git a/lib/core/src/swapper/boltz/mod.rs b/lib/core/src/swapper/boltz/mod.rs index a91463876..5f3583688 100644 --- a/lib/core/src/swapper/boltz/mod.rs +++ b/lib/core/src/swapper/boltz/mod.rs @@ -180,7 +180,7 @@ impl Swapper for BoltzSwapper { Ok((pair_outgoing, pair_incoming)) } - fn get_zero_amount_chain_swap_quote(&self, swap_id: &str) -> Result { + fn get_zero_amount_chain_swap_quote(&self, swap_id: &str) -> Result { self.client .get_quote(swap_id) .map(|r| Amount::from_sat(r.amount)) diff --git a/lib/core/src/swapper/mod.rs b/lib/core/src/swapper/mod.rs index bf67d9a71..dfc78c805 100644 --- a/lib/core/src/swapper/mod.rs +++ b/lib/core/src/swapper/mod.rs @@ -45,7 +45,7 @@ pub trait Swapper: Send + Sync { /// /// If the user locked-up funds in the valid range this will return that amount. In all other /// cases, this will return an error. - fn get_zero_amount_chain_swap_quote(&self, swap_id: &str) -> Result; + fn get_zero_amount_chain_swap_quote(&self, swap_id: &str) -> Result; /// Accept a specific quote for a Zero-Amount Receive Chain Swap fn accept_zero_amount_chain_swap_quote( diff --git a/lib/core/src/sync/mod.rs b/lib/core/src/sync/mod.rs index e32511a4d..c01889e31 100644 --- a/lib/core/src/sync/mod.rs +++ b/lib/core/src/sync/mod.rs @@ -570,6 +570,7 @@ mod tests { None, true, None, + false, ))?; sync_service.push().await?; diff --git a/lib/core/src/sync/model/data.rs b/lib/core/src/sync/model/data.rs index c5deb62d7..e79e677db 100644 --- a/lib/core/src/sync/model/data.rs +++ b/lib/core/src/sync/model/data.rs @@ -21,6 +21,7 @@ pub(crate) struct ChainSyncData { pub(crate) timeout_block_height: u32, pub(crate) payer_amount_sat: u64, pub(crate) receiver_amount_sat: u64, + pub(crate) accepted_receiver_amount_sat: Option, pub(crate) accept_zero_conf: bool, pub(crate) created_at: u32, pub(crate) description: Option, @@ -31,6 +32,9 @@ impl ChainSyncData { for field in updated_fields { match field.as_str() { "accept_zero_conf" => self.accept_zero_conf = other.accept_zero_conf, + "accepted_receiver_amount_sat" => { + self.accepted_receiver_amount_sat = other.accepted_receiver_amount_sat + } _ => continue, } } @@ -46,6 +50,9 @@ impl ChainSyncData { if update.accept_zero_conf != swap.accept_zero_conf { updated_fields.push("accept_zero_conf".to_string()); } + if update.accepted_receiver_amount_sat != swap.accepted_receiver_amount_sat { + updated_fields.push("accepted_receiver_amount_sat".to_string()); + } Some(updated_fields) } None => None, @@ -68,6 +75,7 @@ impl From for ChainSyncData { timeout_block_height: value.timeout_block_height, payer_amount_sat: value.payer_amount_sat, receiver_amount_sat: value.receiver_amount_sat, + accepted_receiver_amount_sat: value.accepted_receiver_amount_sat, accept_zero_conf: value.accept_zero_conf, created_at: value.created_at, description: value.description, @@ -85,7 +93,9 @@ impl From for ChainSwap { preimage: val.preimage, description: val.description, payer_amount_sat: val.payer_amount_sat, + actual_payer_amount_sat: None, receiver_amount_sat: val.receiver_amount_sat, + accepted_receiver_amount_sat: val.accepted_receiver_amount_sat, claim_fees_sat: val.claim_fees_sat, accept_zero_conf: val.accept_zero_conf, pair_fees_json: val.pair_fees_json, diff --git a/lib/core/src/sync/model/mod.rs b/lib/core/src/sync/model/mod.rs index 7038cc4e5..4d2c009b2 100644 --- a/lib/core/src/sync/model/mod.rs +++ b/lib/core/src/sync/model/mod.rs @@ -18,7 +18,7 @@ pub(crate) mod sync; const MESSAGE_PREFIX: &[u8; 13] = b"realtimesync:"; lazy_static! { - static ref CURRENT_SCHEMA_VERSION: Version = Version::parse("0.0.1").unwrap(); + static ref CURRENT_SCHEMA_VERSION: Version = Version::parse("0.1.0").unwrap(); } #[derive(Copy, Clone)] diff --git a/lib/core/src/test_utils/chain.rs b/lib/core/src/test_utils/chain.rs index 0a0073d58..cb3ee8ec2 100644 --- a/lib/core/src/test_utils/chain.rs +++ b/lib/core/src/test_utils/chain.rs @@ -128,17 +128,26 @@ impl LiquidChainService for MockLiquidChainService { pub(crate) struct MockBitcoinChainService { history: Vec, + script_balance_sat: u64, } impl MockBitcoinChainService { pub(crate) fn new() -> Self { - MockBitcoinChainService { history: vec![] } + MockBitcoinChainService { + history: vec![], + script_balance_sat: 0, + } } pub(crate) fn set_history(&mut self, history: Vec) -> &mut Self { self.history = history; self } + + pub(crate) fn set_script_balance_sat(&mut self, script_balance_sat: u64) -> &mut Self { + self.script_balance_sat = script_balance_sat; + self + } } #[async_trait] @@ -208,7 +217,10 @@ impl BitcoinChainService for MockBitcoinChainService { _script: &boltz_client::bitcoin::Script, _retries: u64, ) -> Result { - unimplemented!() + Ok(GetBalanceRes { + confirmed: self.script_balance_sat, + unconfirmed: 0, + }) } async fn verify_tx( diff --git a/lib/core/src/test_utils/chain_swap.rs b/lib/core/src/test_utils/chain_swap.rs index 82467d10f..affdaa04e 100644 --- a/lib/core/src/test_utils/chain_swap.rs +++ b/lib/core/src/test_utils/chain_swap.rs @@ -49,7 +49,96 @@ pub(crate) fn new_chain_swap( payment_state: Option, accept_zero_conf: bool, user_lockup_tx_id: Option, + zero_amount: bool, ) -> ChainSwap { + if zero_amount { + if direction == Direction::Outgoing { + panic!("Zero amount swaps must be incoming") + } + return ChainSwap { + id: generate_random_string(4), + direction: Direction::Incoming, + claim_address: None, + lockup_address: "tb1p7cftn5u3ndt8ln0m6hruwyhsz8kc5sxt557ua03qcew0z29u5paqh8f7uu" + .to_string(), + timeout_block_height: 2868778, + preimage: "bbce422d96c0386c3a6c1b1fe11fc7be3fdd871c6855db6ab2e319e96ec19c78" + .to_string(), + description: Some("Bitcoin transfer".to_string()), + create_response_json: r#"{ + "claim_details": { + "swapTree": { + "claimLeaf": { + "output": "82012088a914e5ec6c5b814b2d8616c1a0da0acc8b3388cf80d78820e5f32fc89e6947ca08a7855a99ac145f7de599446a0cc0ff4c9aa2694baa1138ac", + "version": 196 + }, + "refundLeaf": { + "output": "20692bbff63e48c1c05c5efeb7080f7c2416d2f9ecb79d217410eabc125f4d2ff0ad0312a716b1", + "version": 196 + } + }, + "lockupAddress": "tlq1pq0gfse32q454tmr30t7yl6lx2sv5sswdzh3j0zygz9v5jwwdq6deaec8ntnjq55yrx300u9ts5ykqnfcpuzrypmtda9yuszq0zpl6j8l9tunvqjyrdm3", + "serverPublicKey": "02692bbff63e48c1c05c5efeb7080f7c2416d2f9ecb79d217410eabc125f4d2ff0", + "timeoutBlockHeight": 1484562, + "amount": 0, + "blindingKey": "ebdd91bb06b2282e879256ff1c1a976016a582fea5418188799b1598281b0a5b" + }, + "lockup_details": { + "swapTree": { + "claimLeaf": { + "output": "82012088a914e5ec6c5b814b2d8616c1a0da0acc8b3388cf80d7882039688adbf0625672ec56e713e65ce809ee84e96525a13a68fe521588bf41628cac", + "version": 192 + }, + "refundLeaf": { + "output": "20edf1db3da18ad19962c8dfd7566048c7dc2e11f3d6580cbfed8f9a1321ffe4c7ad032ac62bb1", + "version": 192 + } + }, + "lockupAddress": "tb1p7cftn5u3ndt8ln0m6hruwyhsz8kc5sxt557ua03qcew0z29u5paqh8f7uu", + "serverPublicKey": "0239688adbf0625672ec56e713e65ce809ee84e96525a13a68fe521588bf41628c", + "timeoutBlockHeight": 2868778, + "amount": 0, + "bip21": "bitcoin:tb1p7cftn5u3ndt8ln0m6hruwyhsz8kc5sxt557ua03qcew0z29u5paqh8f7uu?amount=0.0001836&label=Send%20to%20L-BTC%20address" + } + }"#.to_string(), + claim_private_key: "4b04c3b95570fc48c7f33bc900b801245c2be31b90d41616477574aedc5b9d28" + .to_string(), + refund_private_key: "9e23d322577cfeb2b5490f3f86db58c806004afcb7c88995927bfdfc1c64cd8c" + .to_string(), + payer_amount_sat: 0, + actual_payer_amount_sat: None, + receiver_amount_sat: 0, + accepted_receiver_amount_sat: None, + claim_fees_sat: 144, + server_lockup_tx_id: None, + user_lockup_tx_id, + claim_tx_id: None, + refund_tx_id: None, + created_at: utils::now(), + state: payment_state.unwrap_or(PaymentState::Created), + accept_zero_conf, + pair_fees_json: r#"{ + "hash": "43087e267db95668b9b7c48efcf44d922484870f1bdb8b926e5d6b76bf4d0709", + "rate": 1, + "limits": { + "maximal": 4294967, + "minimal": 10000, + "maximalZeroConf": 0 + }, + "fees": { + "percentage": 0.1, + "minerFees": { + "server": 100, + "user": { + "claim": 100, + "lockup": 100 + } + } + } + }"# + .to_string(), + }; + } match direction { Direction::Incoming => ChainSwap { id: generate_random_string(4), @@ -104,7 +193,9 @@ pub(crate) fn new_chain_swap( claim_private_key: "4b04c3b95570fc48c7f33bc900b801245c2be31b90d41616477574aedc5b9d28".to_string(), refund_private_key: "9e23d322577cfeb2b5490f3f86db58c806004afcb7c88995927bfdfc1c64cd8c".to_string(), payer_amount_sat: 18360, + actual_payer_amount_sat: None, receiver_amount_sat: 17592, + accepted_receiver_amount_sat: None, claim_fees_sat: 144, server_lockup_tx_id: None, user_lockup_tx_id, @@ -186,7 +277,9 @@ pub(crate) fn new_chain_swap( claim_private_key: "7d3cbecfb76cb8eccc2c2131f3e744311d3655377fe8723d23acb55b041b2b16".to_string(), refund_private_key: "2644c60cc6cd454ea809f0e32fc2871ab7c26603e3009e1fd313ae886c137eaa".to_string(), payer_amount_sat: 25490, + actual_payer_amount_sat: None, receiver_amount_sat: 20000, + accepted_receiver_amount_sat: None, claim_fees_sat: 2109, server_lockup_tx_id: None, user_lockup_tx_id, diff --git a/lib/core/src/test_utils/receive_swap.rs b/lib/core/src/test_utils/receive_swap.rs index 93bdee61a..4245132bf 100644 --- a/lib/core/src/test_utils/receive_swap.rs +++ b/lib/core/src/test_utils/receive_swap.rs @@ -21,7 +21,7 @@ pub(crate) fn new_receive_swap_handler(persister: Arc) -> Result> = Arc::new(Box::new(MockSigner::new()?)); let onchain_wallet = Arc::new(MockWallet::new(signer)?); - let swapper = Arc::new(MockSwapper::new()); + let swapper = Arc::new(MockSwapper::default()); let liquid_chain_service = Arc::new(Mutex::new(MockLiquidChainService::new())); Ok(ReceiveSwapHandler::new( diff --git a/lib/core/src/test_utils/sdk.rs b/lib/core/src/test_utils/sdk.rs index 14d69021d..9dfc16db7 100644 --- a/lib/core/src/test_utils/sdk.rs +++ b/lib/core/src/test_utils/sdk.rs @@ -40,6 +40,7 @@ pub(crate) fn new_liquid_sdk( status_stream, liquid_chain_service, bitcoin_chain_service, + None, ) } @@ -49,6 +50,7 @@ pub(crate) fn new_liquid_sdk_with_chain_services( status_stream: Arc, liquid_chain_service: Arc>, bitcoin_chain_service: Arc>, + onchain_fee_rate_leeway_sat_per_vbyte: Option, ) -> Result { let mut config = Config::testnet(None); config.working_dir = persister @@ -56,6 +58,7 @@ pub(crate) fn new_liquid_sdk_with_chain_services( .to_str() .ok_or(anyhow!("An invalid SDK directory was specified"))? .to_string(); + config.onchain_fee_rate_leeway_sat_per_vbyte = onchain_fee_rate_leeway_sat_per_vbyte; let signer: Arc> = Arc::new(Box::new(MockSigner::new()?)); let onchain_wallet = Arc::new(MockWallet::new(signer.clone())?); diff --git a/lib/core/src/test_utils/send_swap.rs b/lib/core/src/test_utils/send_swap.rs index 226d33c69..8cc7adcff 100644 --- a/lib/core/src/test_utils/send_swap.rs +++ b/lib/core/src/test_utils/send_swap.rs @@ -20,7 +20,7 @@ pub(crate) fn new_send_swap_handler(persister: Arc) -> Result> = Arc::new(Box::new(MockSigner::new()?)); let onchain_wallet = Arc::new(MockWallet::new(signer)?); - let swapper = Arc::new(MockSwapper::new()); + let swapper = Arc::new(MockSwapper::default()); let chain_service = Arc::new(Mutex::new(MockLiquidChainService::new())); Ok(SendSwapHandler::new( diff --git a/lib/core/src/test_utils/swapper.rs b/lib/core/src/test_utils/swapper.rs index 322568dc7..fb829098e 100644 --- a/lib/core/src/test_utils/swapper.rs +++ b/lib/core/src/test_utils/swapper.rs @@ -11,8 +11,10 @@ use boltz_client::{ Amount, PublicKey, }; use sdk_common::invoice::parse_invoice; +use std::sync::Mutex; use crate::{ + ensure_sdk, error::{PaymentError, SdkError}, model::{Direction, SendSwap, Swap, Transaction as SdkTransaction, Utxo}, swapper::Swapper, @@ -23,7 +25,15 @@ use crate::{ use super::status_stream::MockStatusStream; #[derive(Default)] -pub struct MockSwapper {} +pub struct ZeroAmountSwapMockConfig { + pub user_lockup_sat: u64, + pub onchain_fee_increase_sat: u64, +} + +#[derive(Default)] +pub struct MockSwapper { + zero_amount_swap_mock_config: Mutex, +} impl MockSwapper { pub(crate) fn new() -> Self { @@ -60,6 +70,26 @@ impl MockSwapper { bip21: None, } } + + pub(crate) fn set_zero_amount_swap_mock_config(&self, config: ZeroAmountSwapMockConfig) { + *self.zero_amount_swap_mock_config.lock().unwrap() = config; + } + + fn get_zero_amount_swap_server_lockup_sat(&self) -> u64 { + let zero_amount_swap_mock_config = self.zero_amount_swap_mock_config.lock().unwrap(); + + let pair = self + .get_chain_pair(Direction::Incoming) + .expect("mock get_chain_pair failed") + .expect("no chainpair in mock"); + + let fees = pair + .fees + .boltz(zero_amount_swap_mock_config.user_lockup_sat) + + pair.fees.server() + + zero_amount_swap_mock_config.onchain_fee_increase_sat; + zero_amount_swap_mock_config.user_lockup_sat - fees + } } impl Swapper for MockSwapper { @@ -314,15 +344,20 @@ impl Swapper for MockSwapper { unimplemented!() } - fn get_zero_amount_chain_swap_quote(&self, _swap_id: &str) -> Result { - unimplemented!() + fn get_zero_amount_chain_swap_quote(&self, _swap_id: &str) -> Result { + let server_lockup_amount_sat = self.get_zero_amount_swap_server_lockup_sat(); + Ok(Amount::from_sat(server_lockup_amount_sat)) } fn accept_zero_amount_chain_swap_quote( &self, _swap_id: &str, - _server_lockup_sat: u64, + server_lockup_sat: u64, ) -> Result<(), PaymentError> { - unimplemented!() + ensure_sdk!( + server_lockup_sat == self.get_zero_amount_swap_server_lockup_sat(), + PaymentError::InvalidOrExpiredFees + ); + Ok(()) } } diff --git a/lib/core/src/test_utils/sync.rs b/lib/core/src/test_utils/sync.rs index a4ab84e3a..9a7e86267 100644 --- a/lib/core/src/test_utils/sync.rs +++ b/lib/core/src/test_utils/sync.rs @@ -161,6 +161,7 @@ pub(crate) fn new_chain_sync_data(accept_zero_conf: Option) -> ChainSyncDa timeout_block_height: 0, payer_amount_sat: 0, receiver_amount_sat: 0, + accepted_receiver_amount_sat: None, accept_zero_conf: accept_zero_conf.unwrap_or(true), created_at: 0, description: None, diff --git a/packages/dart/lib/src/frb_generated.dart b/packages/dart/lib/src/frb_generated.dart index bd7749cef..798674ed6 100644 --- a/packages/dart/lib/src/frb_generated.dart +++ b/packages/dart/lib/src/frb_generated.dart @@ -1699,7 +1699,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { Config dco_decode_config(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 13) throw Exception('unexpected arr length: expect 13 but see ${arr.length}'); + if (arr.length != 14) throw Exception('unexpected arr length: expect 14 but see ${arr.length}'); return Config( liquidElectrumUrl: dco_decode_String(arr[0]), bitcoinElectrumUrl: dco_decode_String(arr[1]), @@ -1714,6 +1714,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { breezApiKey: dco_decode_opt_String(arr[10]), externalInputParsers: dco_decode_opt_list_external_input_parser(arr[11]), useDefaultExternalInputParsers: dco_decode_bool(arr[12]), + onchainFeeRateLeewaySatPerVbyte: dco_decode_opt_box_autoadd_u_32(arr[13]), ); } @@ -3004,6 +3005,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { details: dco_decode_box_autoadd_payment(raw[1]), ); case 6: + return SdkEvent_PaymentWaitingFeeAcceptance( + details: dco_decode_box_autoadd_payment(raw[1]), + ); + case 7: return SdkEvent_Synced(); default: throw Exception("unreachable"); @@ -3670,6 +3675,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_breezApiKey = sse_decode_opt_String(deserializer); var var_externalInputParsers = sse_decode_opt_list_external_input_parser(deserializer); var var_useDefaultExternalInputParsers = sse_decode_bool(deserializer); + var var_onchainFeeRateLeewaySatPerVbyte = sse_decode_opt_box_autoadd_u_32(deserializer); return Config( liquidElectrumUrl: var_liquidElectrumUrl, bitcoinElectrumUrl: var_bitcoinElectrumUrl, @@ -3683,7 +3689,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { zeroConfMaxAmountSat: var_zeroConfMaxAmountSat, breezApiKey: var_breezApiKey, externalInputParsers: var_externalInputParsers, - useDefaultExternalInputParsers: var_useDefaultExternalInputParsers); + useDefaultExternalInputParsers: var_useDefaultExternalInputParsers, + onchainFeeRateLeewaySatPerVbyte: var_onchainFeeRateLeewaySatPerVbyte); } @protected @@ -5084,6 +5091,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_details = sse_decode_box_autoadd_payment(deserializer); return SdkEvent_PaymentWaitingConfirmation(details: var_details); case 6: + var var_details = sse_decode_box_autoadd_payment(deserializer); + return SdkEvent_PaymentWaitingFeeAcceptance(details: var_details); + case 7: return SdkEvent_Synced(); default: throw UnimplementedError(''); @@ -5826,6 +5836,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(self.breezApiKey, serializer); sse_encode_opt_list_external_input_parser(self.externalInputParsers, serializer); sse_encode_bool(self.useDefaultExternalInputParsers, serializer); + sse_encode_opt_box_autoadd_u_32(self.onchainFeeRateLeewaySatPerVbyte, serializer); } @protected @@ -6974,8 +6985,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { case SdkEvent_PaymentWaitingConfirmation(details: final details): sse_encode_i_32(5, serializer); sse_encode_box_autoadd_payment(details, serializer); - case SdkEvent_Synced(): + case SdkEvent_PaymentWaitingFeeAcceptance(details: final details): sse_encode_i_32(6, serializer); + sse_encode_box_autoadd_payment(details, serializer); + case SdkEvent_Synced(): + sse_encode_i_32(7, serializer); default: throw UnimplementedError(''); } diff --git a/packages/dart/lib/src/frb_generated.io.dart b/packages/dart/lib/src/frb_generated.io.dart index 4a08438fe..ddaf430a5 100644 --- a/packages/dart/lib/src/frb_generated.io.dart +++ b/packages/dart/lib/src/frb_generated.io.dart @@ -2280,6 +2280,8 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wireObj.breez_api_key = cst_encode_opt_String(apiObj.breezApiKey); wireObj.external_input_parsers = cst_encode_opt_list_external_input_parser(apiObj.externalInputParsers); wireObj.use_default_external_input_parsers = cst_encode_bool(apiObj.useDefaultExternalInputParsers); + wireObj.onchain_fee_rate_leeway_sat_per_vbyte = + cst_encode_opt_box_autoadd_u_32(apiObj.onchainFeeRateLeewaySatPerVbyte); } @protected @@ -3217,8 +3219,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { wireObj.kind.PaymentWaitingConfirmation.details = pre_details; return; } - if (apiObj is SdkEvent_Synced) { + if (apiObj is SdkEvent_PaymentWaitingFeeAcceptance) { + var pre_details = cst_encode_box_autoadd_payment(apiObj.details); wireObj.tag = 6; + wireObj.kind.PaymentWaitingFeeAcceptance.details = pre_details; + return; + } + if (apiObj is SdkEvent_Synced) { + wireObj.tag = 7; return; } } @@ -6222,6 +6230,10 @@ final class wire_cst_SdkEvent_PaymentWaitingConfirmation extends ffi.Struct { external ffi.Pointer details; } +final class wire_cst_SdkEvent_PaymentWaitingFeeAcceptance extends ffi.Struct { + external ffi.Pointer details; +} + final class SdkEventKind extends ffi.Union { external wire_cst_SdkEvent_PaymentFailed PaymentFailed; @@ -6234,6 +6246,8 @@ final class SdkEventKind extends ffi.Union { external wire_cst_SdkEvent_PaymentSucceeded PaymentSucceeded; external wire_cst_SdkEvent_PaymentWaitingConfirmation PaymentWaitingConfirmation; + + external wire_cst_SdkEvent_PaymentWaitingFeeAcceptance PaymentWaitingFeeAcceptance; } final class wire_cst_sdk_event extends ffi.Struct { @@ -6288,6 +6302,8 @@ final class wire_cst_config extends ffi.Struct { @ffi.Bool() external bool use_default_external_input_parsers; + + external ffi.Pointer onchain_fee_rate_leeway_sat_per_vbyte; } final class wire_cst_connect_request extends ffi.Struct { @@ -6906,6 +6922,8 @@ final class wire_cst_sign_message_response extends ffi.Struct { const int ESTIMATED_BTC_CLAIM_TX_VSIZE = 111; +const int ESTIMATED_BTC_LOCKUP_TX_VSIZE = 154; + const double STANDARD_FEE_RATE_SAT_PER_VBYTE = 0.1; const double LOWBALL_FEE_RATE_SAT_PER_VBYTE = 0.01; diff --git a/packages/dart/lib/src/model.dart b/packages/dart/lib/src/model.dart index c360b9ee2..ac2698089 100644 --- a/packages/dart/lib/src/model.dart +++ b/packages/dart/lib/src/model.dart @@ -154,6 +154,14 @@ class Config { /// Set this to false in order to prevent their use. final bool useDefaultExternalInputParsers; + /// For payments where the onchain fees can only be estimated on creation, this can be used + /// in order to automatically allow slightly more expensive fees. If the actual fee rate ends up + /// being above the sum of the initial estimate and this leeway, the payment will require + /// user fee acceptance. See [WaitingFeeAcceptance](PaymentState::WaitingFeeAcceptance). + /// + /// Defaults to zero. + final int? onchainFeeRateLeewaySatPerVbyte; + const Config({ required this.liquidElectrumUrl, required this.bitcoinElectrumUrl, @@ -168,6 +176,7 @@ class Config { this.breezApiKey, this.externalInputParsers, required this.useDefaultExternalInputParsers, + this.onchainFeeRateLeewaySatPerVbyte, }); @override @@ -184,7 +193,8 @@ class Config { zeroConfMaxAmountSat.hashCode ^ breezApiKey.hashCode ^ externalInputParsers.hashCode ^ - useDefaultExternalInputParsers.hashCode; + useDefaultExternalInputParsers.hashCode ^ + onchainFeeRateLeewaySatPerVbyte.hashCode; @override bool operator ==(Object other) => @@ -203,7 +213,8 @@ class Config { zeroConfMaxAmountSat == other.zeroConfMaxAmountSat && breezApiKey == other.breezApiKey && externalInputParsers == other.externalInputParsers && - useDefaultExternalInputParsers == other.useDefaultExternalInputParsers; + useDefaultExternalInputParsers == other.useDefaultExternalInputParsers && + onchainFeeRateLeewaySatPerVbyte == other.onchainFeeRateLeewaySatPerVbyte; } /// An argument when calling [crate::sdk::LiquidSdk::connect]. @@ -817,6 +828,19 @@ enum PaymentState { /// /// When the refund tx is broadcast, `refund_tx_id` is set in the swap. refundPending, + + /// ## Chain Swaps + /// + /// This is the state when the user needs to accept new fees before the payment can proceed. + /// + /// Use [LiquidSdk::fetch_payment_proposed_fees](crate::sdk::LiquidSdk::fetch_payment_proposed_fees) + /// to find out the current fees and + /// [LiquidSdk::accept_payment_proposed_fees](crate::sdk::LiquidSdk::accept_payment_proposed_fees) + /// to accept them, allowing the payment to proceed. + /// + /// Otherwise, this payment can be immediately refunded using + /// [prepare_refund](crate::sdk::LiquidSdk::prepare_refund)/[refund](crate::sdk::LiquidSdk::refund). + waitingFeeAcceptance, ; } @@ -1385,6 +1409,9 @@ sealed class SdkEvent with _$SdkEvent { const factory SdkEvent.paymentWaitingConfirmation({ required Payment details, }) = SdkEvent_PaymentWaitingConfirmation; + const factory SdkEvent.paymentWaitingFeeAcceptance({ + required Payment details, + }) = SdkEvent_PaymentWaitingFeeAcceptance; const factory SdkEvent.synced() = SdkEvent_Synced; } diff --git a/packages/dart/lib/src/model.freezed.dart b/packages/dart/lib/src/model.freezed.dart index a712d5559..8b3d20ab0 100644 --- a/packages/dart/lib/src/model.freezed.dart +++ b/packages/dart/lib/src/model.freezed.dart @@ -1724,6 +1724,88 @@ abstract class SdkEvent_PaymentWaitingConfirmation extends SdkEvent { get copyWith => throw _privateConstructorUsedError; } +/// @nodoc +abstract class _$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWith<$Res> { + factory _$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWith( + _$SdkEvent_PaymentWaitingFeeAcceptanceImpl value, + $Res Function(_$SdkEvent_PaymentWaitingFeeAcceptanceImpl) then) = + __$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWithImpl<$Res>; + @useResult + $Res call({Payment details}); +} + +/// @nodoc +class __$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWithImpl<$Res> + extends _$SdkEventCopyWithImpl<$Res, _$SdkEvent_PaymentWaitingFeeAcceptanceImpl> + implements _$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWith<$Res> { + __$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWithImpl(_$SdkEvent_PaymentWaitingFeeAcceptanceImpl _value, + $Res Function(_$SdkEvent_PaymentWaitingFeeAcceptanceImpl) _then) + : super(_value, _then); + + /// Create a copy of SdkEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? details = null, + }) { + return _then(_$SdkEvent_PaymentWaitingFeeAcceptanceImpl( + details: null == details + ? _value.details + : details // ignore: cast_nullable_to_non_nullable + as Payment, + )); + } +} + +/// @nodoc + +class _$SdkEvent_PaymentWaitingFeeAcceptanceImpl extends SdkEvent_PaymentWaitingFeeAcceptance { + const _$SdkEvent_PaymentWaitingFeeAcceptanceImpl({required this.details}) : super._(); + + @override + final Payment details; + + @override + String toString() { + return 'SdkEvent.paymentWaitingFeeAcceptance(details: $details)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SdkEvent_PaymentWaitingFeeAcceptanceImpl && + (identical(other.details, details) || other.details == details)); + } + + @override + int get hashCode => Object.hash(runtimeType, details); + + /// Create a copy of SdkEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWith<_$SdkEvent_PaymentWaitingFeeAcceptanceImpl> + get copyWith => __$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWithImpl< + _$SdkEvent_PaymentWaitingFeeAcceptanceImpl>(this, _$identity); +} + +abstract class SdkEvent_PaymentWaitingFeeAcceptance extends SdkEvent { + const factory SdkEvent_PaymentWaitingFeeAcceptance({required final Payment details}) = + _$SdkEvent_PaymentWaitingFeeAcceptanceImpl; + const SdkEvent_PaymentWaitingFeeAcceptance._() : super._(); + + Payment get details; + + /// Create a copy of SdkEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SdkEvent_PaymentWaitingFeeAcceptanceImplCopyWith<_$SdkEvent_PaymentWaitingFeeAcceptanceImpl> + get copyWith => throw _privateConstructorUsedError; +} + /// @nodoc abstract class _$$SdkEvent_SyncedImplCopyWith<$Res> { factory _$$SdkEvent_SyncedImplCopyWith( diff --git a/packages/flutter/lib/flutter_breez_liquid_bindings_generated.dart b/packages/flutter/lib/flutter_breez_liquid_bindings_generated.dart index 42a12c1da..fd0cfb0f0 100644 --- a/packages/flutter/lib/flutter_breez_liquid_bindings_generated.dart +++ b/packages/flutter/lib/flutter_breez_liquid_bindings_generated.dart @@ -1644,6 +1644,27 @@ class FlutterBreezLiquidBindings { _uniffi_breez_sdk_liquid_bindings_fn_free_bindingliquidsdkPtr .asFunction, ffi.Pointer)>(); + void uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_accept_payment_proposed_fees( + ffi.Pointer ptr, + RustBuffer req, + ffi.Pointer out_status, + ) { + return _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_accept_payment_proposed_fees( + ptr, + req, + out_status, + ); + } + + late final _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_accept_payment_proposed_feesPtr = + _lookup< + ffi.NativeFunction< + ffi.Void Function(ffi.Pointer, RustBuffer, ffi.Pointer)>>( + 'uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_accept_payment_proposed_fees'); + late final _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_accept_payment_proposed_fees = + _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_accept_payment_proposed_feesPtr + .asFunction, RustBuffer, ffi.Pointer)>(); + RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_add_event_listener( ffi.Pointer ptr, int listener, @@ -1792,6 +1813,27 @@ class FlutterBreezLiquidBindings { _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_onchain_limitsPtr .asFunction, ffi.Pointer)>(); + RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_payment_proposed_fees( + ffi.Pointer ptr, + RustBuffer req, + ffi.Pointer out_status, + ) { + return _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_payment_proposed_fees( + ptr, + req, + out_status, + ); + } + + late final _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_payment_proposed_feesPtr = + _lookup< + ffi.NativeFunction< + RustBuffer Function(ffi.Pointer, RustBuffer, ffi.Pointer)>>( + 'uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_payment_proposed_fees'); + late final _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_payment_proposed_fees = + _uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_fetch_payment_proposed_feesPtr + .asFunction, RustBuffer, ffi.Pointer)>(); + RustBuffer uniffi_breez_sdk_liquid_bindings_fn_method_bindingliquidsdk_get_info( ffi.Pointer ptr, ffi.Pointer out_status, @@ -3421,6 +3463,17 @@ class FlutterBreezLiquidBindings { late final _uniffi_breez_sdk_liquid_bindings_checksum_func_set_logger = _uniffi_breez_sdk_liquid_bindings_checksum_func_set_loggerPtr.asFunction(); + int uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_accept_payment_proposed_fees() { + return _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_accept_payment_proposed_fees(); + } + + late final _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_accept_payment_proposed_feesPtr = + _lookup>( + 'uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_accept_payment_proposed_fees'); + late final _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_accept_payment_proposed_fees = + _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_accept_payment_proposed_feesPtr + .asFunction(); + int uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_add_event_listener() { return _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_add_event_listener(); } @@ -3509,6 +3562,17 @@ class FlutterBreezLiquidBindings { _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_onchain_limitsPtr .asFunction(); + int uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_payment_proposed_fees() { + return _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_payment_proposed_fees(); + } + + late final _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_payment_proposed_feesPtr = + _lookup>( + 'uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_payment_proposed_fees'); + late final _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_payment_proposed_fees = + _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_fetch_payment_proposed_feesPtr + .asFunction(); + int uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_get_info() { return _uniffi_breez_sdk_liquid_bindings_checksum_method_bindingliquidsdk_get_info(); } @@ -4642,6 +4706,10 @@ final class wire_cst_SdkEvent_PaymentWaitingConfirmation extends ffi.Struct { external ffi.Pointer details; } +final class wire_cst_SdkEvent_PaymentWaitingFeeAcceptance extends ffi.Struct { + external ffi.Pointer details; +} + final class SdkEventKind extends ffi.Union { external wire_cst_SdkEvent_PaymentFailed PaymentFailed; @@ -4654,6 +4722,8 @@ final class SdkEventKind extends ffi.Union { external wire_cst_SdkEvent_PaymentSucceeded PaymentSucceeded; external wire_cst_SdkEvent_PaymentWaitingConfirmation PaymentWaitingConfirmation; + + external wire_cst_SdkEvent_PaymentWaitingFeeAcceptance PaymentWaitingFeeAcceptance; } final class wire_cst_sdk_event extends ffi.Struct { @@ -4708,6 +4778,8 @@ final class wire_cst_config extends ffi.Struct { @ffi.Bool() external bool use_default_external_input_parsers; + + external ffi.Pointer onchain_fee_rate_leeway_sat_per_vbyte; } final class wire_cst_connect_request extends ffi.Struct { @@ -5367,6 +5439,8 @@ typedef DartUniFfiRustFutureContinuationFunction = void Function(ffi.Pointer { + val list = ArrayList() + for (value in arr.toList()) { + when (value) { + is ReadableMap -> list.add(asAcceptPaymentProposedFeesRequest(value)!!) + else -> throw SdkException.Generic(errUnexpectedType(value)) + } + } + return list +} + fun asAesSuccessActionData(aesSuccessActionData: ReadableMap): AesSuccessActionData? { if (!validateMandatoryFields( aesSuccessActionData, @@ -284,6 +314,16 @@ fun asConfig(config: ReadableMap): Config? { } else { null } + val onchainFeeRateLeewaySatPerVbyte = + if (hasNonNullKey( + config, + "onchainFeeRateLeewaySatPerVbyte", + ) + ) { + config.getInt("onchainFeeRateLeewaySatPerVbyte").toUInt() + } else { + null + } return Config( liquidElectrumUrl, bitcoinElectrumUrl, @@ -298,6 +338,7 @@ fun asConfig(config: ReadableMap): Config? { zeroConfMaxAmountSat, useDefaultExternalInputParsers, externalInputParsers, + onchainFeeRateLeewaySatPerVbyte, ) } @@ -316,6 +357,7 @@ fun readableMapOf(config: Config): ReadableMap = "zeroConfMaxAmountSat" to config.zeroConfMaxAmountSat, "useDefaultExternalInputParsers" to config.useDefaultExternalInputParsers, "externalInputParsers" to config.externalInputParsers?.let { readableArrayOf(it) }, + "onchainFeeRateLeewaySatPerVbyte" to config.onchainFeeRateLeewaySatPerVbyte, ) fun asConfigList(arr: ReadableArray): List { @@ -473,6 +515,75 @@ fun asExternalInputParserList(arr: ReadableArray): List { return list } +fun asFetchPaymentProposedFeesRequest(fetchPaymentProposedFeesRequest: ReadableMap): FetchPaymentProposedFeesRequest? { + if (!validateMandatoryFields( + fetchPaymentProposedFeesRequest, + arrayOf( + "swapId", + ), + ) + ) { + return null + } + val swapId = fetchPaymentProposedFeesRequest.getString("swapId")!! + return FetchPaymentProposedFeesRequest(swapId) +} + +fun readableMapOf(fetchPaymentProposedFeesRequest: FetchPaymentProposedFeesRequest): ReadableMap = + readableMapOf( + "swapId" to fetchPaymentProposedFeesRequest.swapId, + ) + +fun asFetchPaymentProposedFeesRequestList(arr: ReadableArray): List { + val list = ArrayList() + for (value in arr.toList()) { + when (value) { + is ReadableMap -> list.add(asFetchPaymentProposedFeesRequest(value)!!) + else -> throw SdkException.Generic(errUnexpectedType(value)) + } + } + return list +} + +fun asFetchPaymentProposedFeesResponse(fetchPaymentProposedFeesResponse: ReadableMap): FetchPaymentProposedFeesResponse? { + if (!validateMandatoryFields( + fetchPaymentProposedFeesResponse, + arrayOf( + "swapId", + "feesSat", + "payerAmountSat", + "receiverAmountSat", + ), + ) + ) { + return null + } + val swapId = fetchPaymentProposedFeesResponse.getString("swapId")!! + val feesSat = fetchPaymentProposedFeesResponse.getDouble("feesSat").toULong() + val payerAmountSat = fetchPaymentProposedFeesResponse.getDouble("payerAmountSat").toULong() + val receiverAmountSat = fetchPaymentProposedFeesResponse.getDouble("receiverAmountSat").toULong() + return FetchPaymentProposedFeesResponse(swapId, feesSat, payerAmountSat, receiverAmountSat) +} + +fun readableMapOf(fetchPaymentProposedFeesResponse: FetchPaymentProposedFeesResponse): ReadableMap = + readableMapOf( + "swapId" to fetchPaymentProposedFeesResponse.swapId, + "feesSat" to fetchPaymentProposedFeesResponse.feesSat, + "payerAmountSat" to fetchPaymentProposedFeesResponse.payerAmountSat, + "receiverAmountSat" to fetchPaymentProposedFeesResponse.receiverAmountSat, + ) + +fun asFetchPaymentProposedFeesResponseList(arr: ReadableArray): List { + val list = ArrayList() + for (value in arr.toList()) { + when (value) { + is ReadableMap -> list.add(asFetchPaymentProposedFeesResponse(value)!!) + else -> throw SdkException.Generic(errUnexpectedType(value)) + } + } + return list +} + fun asFiatCurrency(fiatCurrency: ReadableMap): FiatCurrency? { if (!validateMandatoryFields( fiatCurrency, @@ -3230,6 +3341,10 @@ fun asSdkEvent(sdkEvent: ReadableMap): SdkEvent? { val details = sdkEvent.getMap("details")?.let { asPayment(it) }!! return SdkEvent.PaymentWaitingConfirmation(details) } + if (type == "paymentWaitingFeeAcceptance") { + val details = sdkEvent.getMap("details")?.let { asPayment(it) }!! + return SdkEvent.PaymentWaitingFeeAcceptance(details) + } if (type == "synced") { return SdkEvent.Synced } @@ -3263,6 +3378,10 @@ fun readableMapOf(sdkEvent: SdkEvent): ReadableMap? { pushToMap(map, "type", "paymentWaitingConfirmation") pushToMap(map, "details", readableMapOf(sdkEvent.details)) } + is SdkEvent.PaymentWaitingFeeAcceptance -> { + pushToMap(map, "type", "paymentWaitingFeeAcceptance") + pushToMap(map, "details", readableMapOf(sdkEvent.details)) + } is SdkEvent.Synced -> { pushToMap(map, "type", "synced") } diff --git a/packages/react-native/android/src/main/java/com/breezsdkliquid/BreezSDKLiquidModule.kt b/packages/react-native/android/src/main/java/com/breezsdkliquid/BreezSDKLiquidModule.kt index 43f31b8c7..df50dea84 100644 --- a/packages/react-native/android/src/main/java/com/breezsdkliquid/BreezSDKLiquidModule.kt +++ b/packages/react-native/android/src/main/java/com/breezsdkliquid/BreezSDKLiquidModule.kt @@ -420,6 +420,42 @@ class BreezSDKLiquidModule( } } + @ReactMethod + fun fetchPaymentProposedFees( + req: ReadableMap, + promise: Promise, + ) { + executor.execute { + try { + val fetchPaymentProposedFeesRequest = + asFetchPaymentProposedFeesRequest(req) + ?: run { throw SdkException.Generic(errMissingMandatoryField("req", "FetchPaymentProposedFeesRequest")) } + val res = getBindingLiquidSdk().fetchPaymentProposedFees(fetchPaymentProposedFeesRequest) + promise.resolve(readableMapOf(res)) + } catch (e: Exception) { + promise.reject(e.javaClass.simpleName.replace("Exception", "Error"), e.message, e) + } + } + } + + @ReactMethod + fun acceptPaymentProposedFees( + req: ReadableMap, + promise: Promise, + ) { + executor.execute { + try { + val acceptPaymentProposedFeesRequest = + asAcceptPaymentProposedFeesRequest(req) + ?: run { throw SdkException.Generic(errMissingMandatoryField("req", "AcceptPaymentProposedFeesRequest")) } + getBindingLiquidSdk().acceptPaymentProposedFees(acceptPaymentProposedFeesRequest) + promise.resolve(readableMapOf("status" to "ok")) + } catch (e: Exception) { + promise.reject(e.javaClass.simpleName.replace("Exception", "Error"), e.message, e) + } + } + } + @ReactMethod fun listRefundables(promise: Promise) { executor.execute { diff --git a/packages/react-native/ios/BreezSDKLiquidMapper.swift b/packages/react-native/ios/BreezSDKLiquidMapper.swift index 6e85f25f1..2523dd35b 100644 --- a/packages/react-native/ios/BreezSDKLiquidMapper.swift +++ b/packages/react-native/ios/BreezSDKLiquidMapper.swift @@ -2,6 +2,38 @@ import BreezSDKLiquid import Foundation enum BreezSDKLiquidMapper { + static func asAcceptPaymentProposedFeesRequest(acceptPaymentProposedFeesRequest: [String: Any?]) throws -> AcceptPaymentProposedFeesRequest { + guard let responseTmp = acceptPaymentProposedFeesRequest["response"] as? [String: Any?] else { + throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "response", typeName: "AcceptPaymentProposedFeesRequest")) + } + let response = try asFetchPaymentProposedFeesResponse(fetchPaymentProposedFeesResponse: responseTmp) + + return AcceptPaymentProposedFeesRequest(response: response) + } + + static func dictionaryOf(acceptPaymentProposedFeesRequest: AcceptPaymentProposedFeesRequest) -> [String: Any?] { + return [ + "response": dictionaryOf(fetchPaymentProposedFeesResponse: acceptPaymentProposedFeesRequest.response), + ] + } + + static func asAcceptPaymentProposedFeesRequestList(arr: [Any]) throws -> [AcceptPaymentProposedFeesRequest] { + var list = [AcceptPaymentProposedFeesRequest]() + for value in arr { + if let val = value as? [String: Any?] { + var acceptPaymentProposedFeesRequest = try asAcceptPaymentProposedFeesRequest(acceptPaymentProposedFeesRequest: val) + list.append(acceptPaymentProposedFeesRequest) + } else { + throw SdkError.Generic(message: errUnexpectedType(typeName: "AcceptPaymentProposedFeesRequest")) + } + } + return list + } + + static func arrayOf(acceptPaymentProposedFeesRequestList: [AcceptPaymentProposedFeesRequest]) -> [Any] { + return acceptPaymentProposedFeesRequestList.map { v -> [String: Any?] in return dictionaryOf(acceptPaymentProposedFeesRequest: v) } + } + static func asAesSuccessActionData(aesSuccessActionData: [String: Any?]) throws -> AesSuccessActionData { guard let description = aesSuccessActionData["description"] as? String else { throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "description", typeName: "AesSuccessActionData")) @@ -339,7 +371,15 @@ enum BreezSDKLiquidMapper { externalInputParsers = try asExternalInputParserList(arr: externalInputParsersTmp) } - return Config(liquidElectrumUrl: liquidElectrumUrl, bitcoinElectrumUrl: bitcoinElectrumUrl, mempoolspaceUrl: mempoolspaceUrl, workingDir: workingDir, network: network, paymentTimeoutSec: paymentTimeoutSec, zeroConfMinFeeRateMsat: zeroConfMinFeeRateMsat, syncServiceUrl: syncServiceUrl, breezApiKey: breezApiKey, cacheDir: cacheDir, zeroConfMaxAmountSat: zeroConfMaxAmountSat, useDefaultExternalInputParsers: useDefaultExternalInputParsers, externalInputParsers: externalInputParsers) + var onchainFeeRateLeewaySatPerVbyte: UInt32? + if hasNonNilKey(data: config, key: "onchainFeeRateLeewaySatPerVbyte") { + guard let onchainFeeRateLeewaySatPerVbyteTmp = config["onchainFeeRateLeewaySatPerVbyte"] as? UInt32 else { + throw SdkError.Generic(message: errUnexpectedValue(fieldName: "onchainFeeRateLeewaySatPerVbyte")) + } + onchainFeeRateLeewaySatPerVbyte = onchainFeeRateLeewaySatPerVbyteTmp + } + + return Config(liquidElectrumUrl: liquidElectrumUrl, bitcoinElectrumUrl: bitcoinElectrumUrl, mempoolspaceUrl: mempoolspaceUrl, workingDir: workingDir, network: network, paymentTimeoutSec: paymentTimeoutSec, zeroConfMinFeeRateMsat: zeroConfMinFeeRateMsat, syncServiceUrl: syncServiceUrl, breezApiKey: breezApiKey, cacheDir: cacheDir, zeroConfMaxAmountSat: zeroConfMaxAmountSat, useDefaultExternalInputParsers: useDefaultExternalInputParsers, externalInputParsers: externalInputParsers, onchainFeeRateLeewaySatPerVbyte: onchainFeeRateLeewaySatPerVbyte) } static func dictionaryOf(config: Config) -> [String: Any?] { @@ -357,6 +397,7 @@ enum BreezSDKLiquidMapper { "zeroConfMaxAmountSat": config.zeroConfMaxAmountSat == nil ? nil : config.zeroConfMaxAmountSat, "useDefaultExternalInputParsers": config.useDefaultExternalInputParsers, "externalInputParsers": config.externalInputParsers == nil ? nil : arrayOf(externalInputParserList: config.externalInputParsers!), + "onchainFeeRateLeewaySatPerVbyte": config.onchainFeeRateLeewaySatPerVbyte == nil ? nil : config.onchainFeeRateLeewaySatPerVbyte, ] } @@ -551,6 +592,80 @@ enum BreezSDKLiquidMapper { return externalInputParserList.map { v -> [String: Any?] in return dictionaryOf(externalInputParser: v) } } + static func asFetchPaymentProposedFeesRequest(fetchPaymentProposedFeesRequest: [String: Any?]) throws -> FetchPaymentProposedFeesRequest { + guard let swapId = fetchPaymentProposedFeesRequest["swapId"] as? String else { + throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "swapId", typeName: "FetchPaymentProposedFeesRequest")) + } + + return FetchPaymentProposedFeesRequest(swapId: swapId) + } + + static func dictionaryOf(fetchPaymentProposedFeesRequest: FetchPaymentProposedFeesRequest) -> [String: Any?] { + return [ + "swapId": fetchPaymentProposedFeesRequest.swapId, + ] + } + + static func asFetchPaymentProposedFeesRequestList(arr: [Any]) throws -> [FetchPaymentProposedFeesRequest] { + var list = [FetchPaymentProposedFeesRequest]() + for value in arr { + if let val = value as? [String: Any?] { + var fetchPaymentProposedFeesRequest = try asFetchPaymentProposedFeesRequest(fetchPaymentProposedFeesRequest: val) + list.append(fetchPaymentProposedFeesRequest) + } else { + throw SdkError.Generic(message: errUnexpectedType(typeName: "FetchPaymentProposedFeesRequest")) + } + } + return list + } + + static func arrayOf(fetchPaymentProposedFeesRequestList: [FetchPaymentProposedFeesRequest]) -> [Any] { + return fetchPaymentProposedFeesRequestList.map { v -> [String: Any?] in return dictionaryOf(fetchPaymentProposedFeesRequest: v) } + } + + static func asFetchPaymentProposedFeesResponse(fetchPaymentProposedFeesResponse: [String: Any?]) throws -> FetchPaymentProposedFeesResponse { + guard let swapId = fetchPaymentProposedFeesResponse["swapId"] as? String else { + throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "swapId", typeName: "FetchPaymentProposedFeesResponse")) + } + guard let feesSat = fetchPaymentProposedFeesResponse["feesSat"] as? UInt64 else { + throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "feesSat", typeName: "FetchPaymentProposedFeesResponse")) + } + guard let payerAmountSat = fetchPaymentProposedFeesResponse["payerAmountSat"] as? UInt64 else { + throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "payerAmountSat", typeName: "FetchPaymentProposedFeesResponse")) + } + guard let receiverAmountSat = fetchPaymentProposedFeesResponse["receiverAmountSat"] as? UInt64 else { + throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "receiverAmountSat", typeName: "FetchPaymentProposedFeesResponse")) + } + + return FetchPaymentProposedFeesResponse(swapId: swapId, feesSat: feesSat, payerAmountSat: payerAmountSat, receiverAmountSat: receiverAmountSat) + } + + static func dictionaryOf(fetchPaymentProposedFeesResponse: FetchPaymentProposedFeesResponse) -> [String: Any?] { + return [ + "swapId": fetchPaymentProposedFeesResponse.swapId, + "feesSat": fetchPaymentProposedFeesResponse.feesSat, + "payerAmountSat": fetchPaymentProposedFeesResponse.payerAmountSat, + "receiverAmountSat": fetchPaymentProposedFeesResponse.receiverAmountSat, + ] + } + + static func asFetchPaymentProposedFeesResponseList(arr: [Any]) throws -> [FetchPaymentProposedFeesResponse] { + var list = [FetchPaymentProposedFeesResponse]() + for value in arr { + if let val = value as? [String: Any?] { + var fetchPaymentProposedFeesResponse = try asFetchPaymentProposedFeesResponse(fetchPaymentProposedFeesResponse: val) + list.append(fetchPaymentProposedFeesResponse) + } else { + throw SdkError.Generic(message: errUnexpectedType(typeName: "FetchPaymentProposedFeesResponse")) + } + } + return list + } + + static func arrayOf(fetchPaymentProposedFeesResponseList: [FetchPaymentProposedFeesResponse]) -> [Any] { + return fetchPaymentProposedFeesResponseList.map { v -> [String: Any?] in return dictionaryOf(fetchPaymentProposedFeesResponse: v) } + } + static func asFiatCurrency(fiatCurrency: [String: Any?]) throws -> FiatCurrency { guard let id = fiatCurrency["id"] as? String else { throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "id", typeName: "FiatCurrency")) @@ -3926,6 +4041,9 @@ enum BreezSDKLiquidMapper { case "refundPending": return PaymentState.refundPending + case "waitingFeeAcceptance": + return PaymentState.waitingFeeAcceptance + default: throw SdkError.Generic(message: "Invalid variant \(paymentState) for enum PaymentState") } } @@ -3952,6 +4070,9 @@ enum BreezSDKLiquidMapper { case .refundPending: return "refundPending" + + case .waitingFeeAcceptance: + return "waitingFeeAcceptance" } } @@ -4061,6 +4182,14 @@ enum BreezSDKLiquidMapper { return SdkEvent.paymentWaitingConfirmation(details: _details) } + if type == "paymentWaitingFeeAcceptance" { + guard let detailsTmp = sdkEvent["details"] as? [String: Any?] else { + throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "details", typeName: "SdkEvent")) + } + let _details = try asPayment(payment: detailsTmp) + + return SdkEvent.paymentWaitingFeeAcceptance(details: _details) + } if type == "synced" { return SdkEvent.synced } @@ -4118,6 +4247,14 @@ enum BreezSDKLiquidMapper { "details": dictionaryOf(payment: details), ] + case let .paymentWaitingFeeAcceptance( + details + ): + return [ + "type": "paymentWaitingFeeAcceptance", + "details": dictionaryOf(payment: details), + ] + case .synced: return [ "type": "synced", diff --git a/packages/react-native/ios/RNBreezSDKLiquid.m b/packages/react-native/ios/RNBreezSDKLiquid.m index b9640d45b..7eed3b1e3 100644 --- a/packages/react-native/ios/RNBreezSDKLiquid.m +++ b/packages/react-native/ios/RNBreezSDKLiquid.m @@ -131,6 +131,18 @@ @interface RCT_EXTERN_MODULE(RNBreezSDKLiquid, RCTEventEmitter) reject: (RCTPromiseRejectBlock)reject ) +RCT_EXTERN_METHOD( + fetchPaymentProposedFees: (NSDictionary*)req + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject +) + +RCT_EXTERN_METHOD( + acceptPaymentProposedFees: (NSDictionary*)req + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject +) + RCT_EXTERN_METHOD( listRefundables: (RCTPromiseResolveBlock)resolve reject: (RCTPromiseRejectBlock)reject diff --git a/packages/react-native/ios/RNBreezSDKLiquid.swift b/packages/react-native/ios/RNBreezSDKLiquid.swift index 7dc002004..3775f5c3a 100644 --- a/packages/react-native/ios/RNBreezSDKLiquid.swift +++ b/packages/react-native/ios/RNBreezSDKLiquid.swift @@ -321,6 +321,28 @@ class RNBreezSDKLiquid: RCTEventEmitter { } } + @objc(fetchPaymentProposedFees:resolve:reject:) + func fetchPaymentProposedFees(_ req: [String: Any], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + do { + let fetchPaymentProposedFeesRequest = try BreezSDKLiquidMapper.asFetchPaymentProposedFeesRequest(fetchPaymentProposedFeesRequest: req) + var res = try getBindingLiquidSdk().fetchPaymentProposedFees(req: fetchPaymentProposedFeesRequest) + resolve(BreezSDKLiquidMapper.dictionaryOf(fetchPaymentProposedFeesResponse: res)) + } catch let err { + rejectErr(err: err, reject: reject) + } + } + + @objc(acceptPaymentProposedFees:resolve:reject:) + func acceptPaymentProposedFees(_ req: [String: Any], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + do { + let acceptPaymentProposedFeesRequest = try BreezSDKLiquidMapper.asAcceptPaymentProposedFeesRequest(acceptPaymentProposedFeesRequest: req) + try getBindingLiquidSdk().acceptPaymentProposedFees(req: acceptPaymentProposedFeesRequest) + resolve(["status": "ok"]) + } catch let err { + rejectErr(err: err, reject: reject) + } + } + @objc(listRefundables:reject:) func listRefundables(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { do { diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index c7b48b8c5..f17447e9f 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -19,6 +19,10 @@ const BreezSDKLiquid = NativeModules.RNBreezSDKLiquid const BreezSDKLiquidEmitter = new NativeEventEmitter(BreezSDKLiquid) +export interface AcceptPaymentProposedFeesRequest { + response: FetchPaymentProposedFeesResponse +} + export interface AesSuccessActionData { description: string ciphertext: string @@ -71,6 +75,7 @@ export interface Config { zeroConfMaxAmountSat?: number useDefaultExternalInputParsers: boolean externalInputParsers?: ExternalInputParser[] + onchainFeeRateLeewaySatPerVbyte?: number } export interface ConnectRequest { @@ -98,6 +103,17 @@ export interface ExternalInputParser { parserUrl: string } +export interface FetchPaymentProposedFeesRequest { + swapId: string +} + +export interface FetchPaymentProposedFeesResponse { + swapId: string + feesSat: number + payerAmountSat: number + receiverAmountSat: number +} + export interface FiatCurrency { id: string info: CurrencyInfo @@ -648,7 +664,8 @@ export enum PaymentState { FAILED = "failed", TIMED_OUT = "timedOut", REFUNDABLE = "refundable", - REFUND_PENDING = "refundPending" + REFUND_PENDING = "refundPending", + WAITING_FEE_ACCEPTANCE = "waitingFeeAcceptance" } export enum PaymentType { @@ -663,6 +680,7 @@ export enum SdkEventVariant { PAYMENT_REFUND_PENDING = "paymentRefundPending", PAYMENT_SUCCEEDED = "paymentSucceeded", PAYMENT_WAITING_CONFIRMATION = "paymentWaitingConfirmation", + PAYMENT_WAITING_FEE_ACCEPTANCE = "paymentWaitingFeeAcceptance", SYNCED = "synced" } @@ -684,6 +702,9 @@ export type SdkEvent = { } | { type: SdkEventVariant.PAYMENT_WAITING_CONFIRMATION, details: Payment +} | { + type: SdkEventVariant.PAYMENT_WAITING_FEE_ACCEPTANCE, + details: Payment } | { type: SdkEventVariant.SYNCED } @@ -861,6 +882,15 @@ export const getPayment = async (req: GetPaymentRequest): Promise => { + const response = await BreezSDKLiquid.fetchPaymentProposedFees(req) + return response +} + +export const acceptPaymentProposedFees = async (req: AcceptPaymentProposedFeesRequest): Promise => { + await BreezSDKLiquid.acceptPaymentProposedFees(req) +} + export const listRefundables = async (): Promise => { const response = await BreezSDKLiquid.listRefundables() return response