diff --git a/lib/core/src/chain_swap.rs b/lib/core/src/chain_swap.rs index f4798acd2..f4173934d 100644 --- a/lib/core/src/chain_swap.rs +++ b/lib/core/src/chain_swap.rs @@ -473,18 +473,9 @@ impl ChainSwapStateHandler { warn!("Chain Swap {id} is in an unrecoverable state: {swap_state:?}"); match swap.user_lockup_tx_id.clone() { Some(_) => { - warn!("Chain Swap {id} user lockup tx has been broadcast. Attempting refund."); - let refund_tx_id = self.refund_outgoing_swap(swap).await?; - info!("Broadcast refund tx for Chain Swap {id}. Tx id: {refund_tx_id}"); - self.update_swap_info( - id, - RefundPending, - None, - None, - None, - Some(&refund_tx_id), - ) - .await?; + warn!("Chain Swap {id} user lockup tx has been broadcast. Marking payment as `RefundPending`."); + self.update_swap_info(id, RefundPending, None, None, None, None) + .await?; } None => { warn!("Chain Swap {id} user lockup tx was never broadcast. Resolving payment as failed."); @@ -694,6 +685,7 @@ impl ChainSwapStateHandler { pub(crate) async fn refund_outgoing_swap( &self, swap: &ChainSwap, + is_cooperative: bool, ) -> Result { match swap.refund_tx_id.clone() { Some(refund_tx_id) => Err(PaymentError::Generic { @@ -707,29 +699,23 @@ impl ChainSwapStateHandler { let (_, broadcast_fees_sat) = self.swapper .prepare_chain_swap_refund(swap, &output_address, 0.1)?; - let refund_res = self.swapper.refund_chain_swap_cooperative( - swap, - &output_address, - broadcast_fees_sat, - ); - let refund_tx_id = match refund_res { - Ok(res) => Ok(res), - Err(e) => { - warn!("Cooperative refund failed: {:?}", e); - let current_height = self.liquid_chain_service.lock().await.tip().await?; - self.swapper.refund_chain_swap_non_cooperative( - swap, - broadcast_fees_sat, - &output_address, - current_height, - ) - } - }?; - info!( - "Broadcast refund tx for Chain Swap {}. Tx id: {refund_tx_id}", - swap.id - ); + let refund_tx_id = if is_cooperative { + self.swapper.refund_chain_swap_cooperative( + swap, + &output_address, + broadcast_fees_sat, + )? + } else { + let current_height = self.liquid_chain_service.lock().await.tip().await?; + self.swapper.refund_chain_swap_non_cooperative( + swap, + broadcast_fees_sat, + &output_address, + current_height, + )? + }; + self.update_swap_info( &swap.id, RefundPending, @@ -739,6 +725,12 @@ impl ChainSwapStateHandler { Some(&refund_tx_id), ) .await?; + + info!( + "Broadcast refund tx for Chain Swap {}. Tx id: {refund_tx_id}", + swap.id + ); + Ok(refund_tx_id) } } diff --git a/lib/core/src/model.rs b/lib/core/src/model.rs index c915a770a..c8bb3d53e 100644 --- a/lib/core/src/model.rs +++ b/lib/core/src/model.rs @@ -792,7 +792,7 @@ pub enum PaymentState { /// ## Send and Chain Swaps /// - /// This is the status when a refund was initiated and our refund tx was broadcast + /// This is the status when a refund was initiated and/or our refund tx was broadcast /// /// When the refund tx is broadcast, `refund_tx_id` is set in the swap. RefundPending = 6, diff --git a/lib/core/src/sdk.rs b/lib/core/src/sdk.rs index 0c7d81ae5..4060f65d4 100644 --- a/lib/core/src/sdk.rs +++ b/lib/core/src/sdk.rs @@ -317,8 +317,8 @@ impl LiquidSdk { match cloned.persister.list_pending_send_swaps() { Ok(pending_send_swaps) => { for swap in pending_send_swaps { - if let Err(e) = cloned.check_send_swap_expiration(&swap).await { - error!("Error checking expiration for Send Swap {}: {e:?}", swap.id); + if let Err(e) = cloned.try_send_swap_refund(&swap).await { + error!("Could not execute refund for Send Swap {}: {e:?}", swap.id); } } } @@ -327,8 +327,8 @@ impl LiquidSdk { match cloned.persister.list_pending_chain_swaps() { Ok(pending_chain_swaps) => { for swap in pending_chain_swaps { - if let Err(e) = cloned.check_chain_swap_expiration(&swap).await { - error!("Error checking expiration for Chain Swap {}: {e:?}", swap.id); + if let Err(e) = cloned.try_chain_swap_refund(&swap).await { + error!("Could not execute refund for Chain Swap {}: {e:?}", swap.id); } } } @@ -344,67 +344,117 @@ impl LiquidSdk { }); } - async fn check_chain_swap_expiration(&self, chain_swap: &ChainSwap) -> Result<()> { - if chain_swap.user_lockup_tx_id.is_some() && chain_swap.refund_tx_id.is_none() { - match chain_swap.direction { - Direction::Incoming => { - let swap_script = chain_swap.get_lockup_swap_script()?.as_bitcoin_script()?; - let current_height = - self.bitcoin_chain_service.lock().await.tip()?.height as u32; - let locktime_from_height = - LockTime::from_height(current_height).map_err(|e| { - PaymentError::Generic { - err: format!( - "Error getting locktime from height {current_height:?}: {e}", - ), - } - })?; - - info!("Checking Chain Swap {} expiration: locktime_from_height = {locktime_from_height:?}, swap_script.locktime = {:?}", chain_swap.id, swap_script.locktime); - if swap_script.locktime.is_implied_by(locktime_from_height) { - let id: &String = &chain_swap.id; - info!("Chain Swap {} user lockup tx was broadcast. Setting the swap to refundable.", id); - self.chain_swap_state_handler - .update_swap_info(id, Refundable, None, None, None, None) - .await?; - } - } - Direction::Outgoing => { - let swap_script = chain_swap.get_lockup_swap_script()?.as_liquid_script()?; - let current_height = self.liquid_chain_service.lock().await.tip().await?; - let locktime_from_height = elements::LockTime::from_height(current_height)?; - - info!("Checking Chain Swap {} expiration: locktime_from_height = {locktime_from_height:?}, swap_script.locktime = {:?}", chain_swap.id, swap_script.locktime); - if utils::is_locktime_expired(locktime_from_height, swap_script.locktime) { - self.chain_swap_state_handler - .refund_outgoing_swap(chain_swap) - .await?; - } - } - } + /// Tries refunding a chain swap: + /// ## Outgoing Chain Swap + /// For outgoing chain swaps, we check whether the status is `RefundPending` and + /// the locktime has expired. If so, we refund non-cooperatively; else we refund cooperatively + /// + /// ## Incoming Chain Swap + /// For incoming chain swaps, we check whether the status is `Pending` and the locktime has expired. + /// If so, we set the swap status as `Refundable` + async fn try_chain_swap_refund(&self, chain_swap: &ChainSwap) -> Result<()> { + if chain_swap.user_lockup_tx_id.is_none() || chain_swap.refund_tx_id.is_some() { + return Err(anyhow::anyhow!( + "Lockup_tx not present or refund_tx already broadcast" + )); + } + + if chain_swap.state == PaymentState::RefundPending + && chain_swap.direction == Direction::Outgoing + { + let is_cooperative = !self.check_chain_swap_expiration(chain_swap).await?; + self.chain_swap_state_handler + .refund_outgoing_swap(chain_swap, is_cooperative) + .await?; + } + + if chain_swap.state == PaymentState::Pending + && chain_swap.direction == Direction::Incoming + && self.check_chain_swap_expiration(chain_swap).await? + { + info!( + "Chain Swap {} user lockup tx was broadcast. Setting the swap to refundable.", + &chain_swap.id + ); + self.chain_swap_state_handler + .update_swap_info(&chain_swap.id, Refundable, None, None, None, None) + .await?; } + Ok(()) } - async fn check_send_swap_expiration(&self, send_swap: &SendSwap) -> Result<()> { - if send_swap.lockup_tx_id.is_some() && send_swap.refund_tx_id.is_none() { - let swap_script = send_swap.get_swap_script()?; - let current_height = self.liquid_chain_service.lock().await.tip().await?; - let locktime_from_height = elements::LockTime::from_height(current_height)?; + async fn check_chain_swap_expiration(&self, chain_swap: &ChainSwap) -> Result { + match chain_swap.direction { + Direction::Incoming => { + let swap_script = chain_swap.get_lockup_swap_script()?.as_bitcoin_script()?; + let current_height = self.bitcoin_chain_service.lock().await.tip()?.height as u32; + let locktime_from_height = + LockTime::from_height(current_height).map_err(|e| PaymentError::Generic { + err: format!("Error getting locktime from height {current_height:?}: {e}",), + })?; - info!("Checking Send Swap {} expiration: locktime_from_height = {locktime_from_height:?}, swap_script.locktime = {:?}", send_swap.id, swap_script.locktime); - if utils::is_locktime_expired(locktime_from_height, swap_script.locktime) { - let id = &send_swap.id; - let refund_tx_id = self.send_swap_state_handler.refund(send_swap).await?; - info!("Broadcast refund tx for Send Swap {id}. Tx id: {refund_tx_id}"); - self.send_swap_state_handler - .update_swap_info(id, Pending, None, None, Some(&refund_tx_id)) - .await?; + info!("Checking Chain Swap {} expiration: locktime_from_height = {locktime_from_height:?}, swap_script.locktime = {:?}", chain_swap.id, swap_script.locktime); + Ok(swap_script.locktime.is_implied_by(locktime_from_height)) } + Direction::Outgoing => { + let swap_script = chain_swap.get_lockup_swap_script()?.as_liquid_script()?; + let current_height = self.liquid_chain_service.lock().await.tip().await?; + let locktime_from_height = elements::LockTime::from_height(current_height)?; + + info!("Checking Chain Swap {} expiration: locktime_from_height = {locktime_from_height:?}, swap_script.locktime = {:?}", chain_swap.id, swap_script.locktime); + Ok(utils::is_locktime_expired( + locktime_from_height, + swap_script.locktime, + )) + } + } + } + + /// Tries refunding a pending send swap if swap has been marked as `RefundPending` and: + /// 1. Swap has expired and locktime has elapsed, we try refunding non-cooperatively + /// 2. No `refund_tx_id` is present, we try to refund cooperatively + async fn try_send_swap_refund(&self, send_swap: &SendSwap) -> Result<()> { + if send_swap.state != PaymentState::RefundPending { + return Ok(()); } + + if send_swap.lockup_tx_id.is_none() || send_swap.refund_tx_id.is_some() { + return Err(anyhow::anyhow!( + "Lockup_tx not present or refund_tx already broadcast" + )); + } + + let refund_tx_id = if self.check_send_swap_expiration(send_swap).await? { + debug!("Locktime has elapsed, proceeding with non-cooperative refund."); + self.send_swap_state_handler + .refund_non_cooperative(send_swap) + .await? + } else { + self.send_swap_state_handler + .refund_cooperative(send_swap) + .await? + }; + + self.send_swap_state_handler + .update_swap_info(&send_swap.id, Pending, None, None, Some(&refund_tx_id)) + .await?; + Ok(()) } + async fn check_send_swap_expiration(&self, send_swap: &SendSwap) -> Result { + let swap_script = send_swap.get_swap_script()?; + let current_height = self.liquid_chain_service.lock().await.tip().await?; + let locktime_from_height = elements::LockTime::from_height(current_height)?; + + info!("Checking Send Swap {} expiration: locktime_from_height = {locktime_from_height:?}, swap_script.locktime = {:?}", send_swap.id, swap_script.locktime); + Ok(utils::is_locktime_expired( + locktime_from_height, + swap_script.locktime, + )) + } + async fn notify_event_listeners(&self, e: SdkEvent) -> Result<()> { self.event_manager.notify(e).await; Ok(()) diff --git a/lib/core/src/send_swap.rs b/lib/core/src/send_swap.rs index 9728d644b..5626c7936 100644 --- a/lib/core/src/send_swap.rs +++ b/lib/core/src/send_swap.rs @@ -1,4 +1,3 @@ -use std::time::Duration; use std::{str::FromStr, sync::Arc}; use anyhow::{anyhow, Result}; @@ -26,9 +25,6 @@ use crate::{ persist::Persister, }; -pub(crate) const MAX_REFUND_ATTEMPTS: u8 = 6; -pub(crate) const REFUND_REATTEMPT_DELAY_SECS: u64 = 10; - #[derive(Clone)] pub(crate) struct SendSwapStateHandler { config: Config, @@ -155,45 +151,17 @@ impl SendSwapStateHandler { | SubSwapStates::SwapExpired, ) => { match swap.lockup_tx_id { - Some(_) => { - match swap.refund_tx_id { - Some(refund_tx_id) => warn!( + Some(_) => match swap.refund_tx_id { + Some(refund_tx_id) => warn!( "Refund tx for Send Swap {id} was already broadcast: txid {refund_tx_id}" ), - None => { - warn!("Send Swap {id} is in an unrecoverable state: {swap_state:?}, and lockup tx has been broadcast. Attempting refund."); - - let mut refund_attempts = 0; - while refund_attempts < MAX_REFUND_ATTEMPTS { - let refund_tx_id = match self.refund(&swap).await { - Ok(refund_tx_id) => refund_tx_id, - Err(e) => { - warn!("Could not refund yet: {e:?}. Re-attempting in {REFUND_REATTEMPT_DELAY_SECS} seconds."); - refund_attempts += 1; - std::thread::sleep(Duration::from_secs( - REFUND_REATTEMPT_DELAY_SECS, - )); - continue; - } - }; - info!("Broadcast refund tx for Send Swap {id}. Tx id: {refund_tx_id}"); - self.update_swap_info( - id, - RefundPending, - None, - None, - Some(&refund_tx_id), - ) - .await?; - break; - } - - if refund_attempts == MAX_REFUND_ATTEMPTS { - warn!("Failed to issue refunds: max attempts reached.") - } - } + None => { + warn!("Send Swap {id} is in an unrecoverable state: {swap_state:?}, and lockup tx has been broadcast. Marking payment as `RefundPending`."); + + self.update_swap_info(id, RefundPending, None, None, None) + .await?; } - } + }, // Do not attempt broadcasting a refund if lockup tx was never sent and swap is // unrecoverable. We resolve the payment as failed. None => { @@ -383,53 +351,33 @@ impl SendSwapStateHandler { Ok(()) } - pub(crate) async fn refund(&self, swap: &SendSwap) -> Result { + pub(crate) async fn refund_cooperative(&self, swap: &SendSwap) -> Result { let output_address = self.onchain_wallet.next_unused_address().await?.to_string(); - let cooperative_refund_tx_fees_sat = utils::estimate_refund_fees(swap, &self.config, &output_address, true)?; - let refund_res = self.swapper.refund_send_swap_cooperative( + + self.swapper.refund_send_swap_cooperative( swap, &output_address, cooperative_refund_tx_fees_sat, - ); - - match refund_res { - Ok(res) => Ok(res), - Err(e) => { - warn!("Cooperative refund failed: {:?}", e); - let non_cooperative_refund_tx_fees_sat = - utils::estimate_refund_fees(swap, &self.config, &output_address, false)?; - self.refund_non_cooperative(swap, non_cooperative_refund_tx_fees_sat) - .await - } - } + ) } - async fn refund_non_cooperative( + pub(crate) async fn refund_non_cooperative( &self, swap: &SendSwap, - broadcast_fees_sat: u64, ) -> Result { - info!( - "Initiating non-cooperative refund for Send Swap {}", - &swap.id - ); - let current_height = self.onchain_wallet.tip().await.height(); let output_address = self.onchain_wallet.next_unused_address().await?.to_string(); - let refund_tx_id = self.swapper.refund_send_swap_non_cooperative( + let non_cooperative_refund_tx_fees_sat = + utils::estimate_refund_fees(swap, &self.config, &output_address, false)?; + + self.swapper.refund_send_swap_non_cooperative( swap, - broadcast_fees_sat, + non_cooperative_refund_tx_fees_sat, &output_address, current_height, - )?; - - info!( - "Successfully broadcast non-cooperative refund for Send Swap {}, tx: {}", - swap.id, refund_tx_id - ); - Ok(refund_tx_id) + ) } fn validate_state_transition( diff --git a/lib/core/src/swapper/mod.rs b/lib/core/src/swapper/mod.rs index 581e5ee52..adfca8d9d 100644 --- a/lib/core/src/swapper/mod.rs +++ b/lib/core/src/swapper/mod.rs @@ -591,7 +591,7 @@ impl Swapper for BoltzSwapper { current_height: u32, ) -> Result { info!( - "Initiating non cooperative refund for Chain Swap {}", + "Initiating non-cooperative refund for Chain Swap {}", &swap.id ); let refund_keypair = swap.get_refund_keypair()?; @@ -614,6 +614,10 @@ impl Swapper for BoltzSwapper { output_address: &str, current_height: u32, ) -> Result { + info!( + "Initiating non-cooperative refund for Send Swap {}", + &swap.id + ); let swap_script = SwapScriptV2::Liquid(swap.get_swap_script()?); let refund_keypair = swap.get_refund_keypair()?; self.refund_swap_non_cooperative(