diff --git a/lib/core/src/recover/handlers/handle_send_swap.rs b/lib/core/src/recover/handlers/handle_send_swap.rs index 86396af..10513d0 100644 --- a/lib/core/src/recover/handlers/handle_send_swap.rs +++ b/lib/core/src/recover/handlers/handle_send_swap.rs @@ -61,10 +61,13 @@ impl SendSwapHandler { let mut recovered_data = Self::recover_onchain_data(&context.tx_map, &swap_id, history)?; // Recover preimage if needed - if let (Some(claim_tx_id), None) = (&recovered_data.claim_tx_id, &send_swap.preimage) { + if recovered_data.lockup_tx_id.is_some() && send_swap.preimage.is_none() { + // We can attempt to recover the preimage cooperatively after we know the + // lockup tx was broadcast. If we cannot recover it cooperatively, + // we can try to recover it from the claim tx. match Self::recover_preimage( context, - claim_tx_id.txid, + recovered_data.claim_tx_id.clone(), &swap_id, context.swapper.clone(), ) @@ -130,7 +133,9 @@ impl SendSwapHandler { // Update state based on recovered data let timeout_block_height = send_swap.timeout_block_height as u32; let is_expired = current_block_height >= timeout_block_height; - if let Some(new_state) = recovered_data.derive_partial_state(is_expired) { + if let Some(new_state) = + recovered_data.derive_partial_state(send_swap.preimage.clone(), is_expired) + { send_swap.state = new_state; } @@ -181,30 +186,35 @@ impl SendSwapHandler { }) } - /// Tries to recover the preimage for a send swap from its claim tx + /// Tries to recover the preimage for a send swap async fn recover_preimage( context: &RecoveryContext, - claim_tx_id: Txid, + claim_tx_id: Option, swap_id: &str, swapper: Arc, ) -> Result> { // Try cooperative first if let Ok(preimage) = swapper.get_submarine_preimage(swap_id).await { - log::debug!("Found Send Swap {swap_id} preimage cooperatively: {preimage}"); + log::debug!("Fetched Send Swap {swap_id} preimage cooperatively: {preimage}"); return Ok(Some(preimage)); } warn!("Could not recover Send swap {swap_id} preimage cooperatively"); - let claim_txs = context - .liquid_chain_service - .get_transactions(&[claim_tx_id]) - .await?; - - match claim_txs.is_empty() { - false => Self::extract_preimage_from_claim_tx(&claim_txs[0], swap_id).map(Some), - true => { - warn!("Could not recover Send swap {swap_id} preimage non cooperatively"); - Ok(None) + match claim_tx_id { + // If we have a claim tx id, we can try to recover the preimage from the tx + Some(claim_tx_id) => { + let claim_txs = context + .liquid_chain_service + .get_transactions(&[claim_tx_id.txid]) + .await?; + match claim_txs.is_empty() { + false => Self::extract_preimage_from_claim_tx(&claim_txs[0], swap_id).map(Some), + true => { + warn!("Could not recover Send swap {swap_id} preimage non cooperatively"); + Ok(None) + } + } } + None => Ok(None), } } @@ -245,9 +255,13 @@ pub(crate) struct RecoveredOnchainDataSend { } impl RecoveredOnchainDataSend { - pub(crate) fn derive_partial_state(&self, is_expired: bool) -> Option { + pub(crate) fn derive_partial_state( + &self, + preimage: Option, + is_expired: bool, + ) -> Option { match &self.lockup_tx_id { - Some(_) => match &self.claim_tx_id { + Some(_) => match preimage { Some(_) => Some(PaymentState::Complete), None => match &self.refund_tx_id { Some(refund_tx_id) => match refund_tx_id.confirmed() { diff --git a/lib/core/src/recover/handlers/tests/handle_send_swap_tests.rs b/lib/core/src/recover/handlers/tests/handle_send_swap_tests.rs index 3a69c56..b8bcc67 100644 --- a/lib/core/src/recover/handlers/tests/handle_send_swap_tests.rs +++ b/lib/core/src/recover/handlers/tests/handle_send_swap_tests.rs @@ -12,6 +12,7 @@ mod test { #[sdk_macros::test_all] fn test_derive_partial_state_with_lockup_and_claim() { + let swap_preimage = Some("cccc".to_string()); let recovered_data = RecoveredOnchainDataSend { lockup_tx_id: Some(create_lbtc_history_txid("1111", 100)), claim_tx_id: Some(create_lbtc_history_txid("2222", 101)), @@ -21,11 +22,11 @@ mod test { // When there's a lockup and claim tx, it should always be Complete, regardless of timeout assert_eq!( - recovered_data.derive_partial_state(false), + recovered_data.derive_partial_state(swap_preimage.clone(), false), Some(PaymentState::Complete) ); assert_eq!( - recovered_data.derive_partial_state(true), + recovered_data.derive_partial_state(swap_preimage, true), Some(PaymentState::Complete) ); } @@ -33,6 +34,7 @@ mod test { #[sdk_macros::test_all] fn test_derive_partial_state_with_lockup_and_refund() { // Test with confirmed refund + let no_swap_preimage = None; let recovered_data = RecoveredOnchainDataSend { lockup_tx_id: Some(create_lbtc_history_txid("1111", 100)), claim_tx_id: None, @@ -42,11 +44,11 @@ mod test { // When there's a lockup and confirmed refund tx, it should be Failed assert_eq!( - recovered_data.derive_partial_state(false), + recovered_data.derive_partial_state(no_swap_preimage.clone(), false), Some(PaymentState::Failed) ); assert_eq!( - recovered_data.derive_partial_state(true), + recovered_data.derive_partial_state(no_swap_preimage.clone(), true), Some(PaymentState::Failed) ); @@ -60,17 +62,19 @@ mod test { // When there's a lockup and unconfirmed refund tx, it should be RefundPending assert_eq!( - recovered_data.derive_partial_state(false), + recovered_data.derive_partial_state(no_swap_preimage.clone(), false), Some(PaymentState::RefundPending) ); assert_eq!( - recovered_data.derive_partial_state(true), + recovered_data.derive_partial_state(no_swap_preimage, true), Some(PaymentState::RefundPending) ); } #[sdk_macros::test_all] fn test_derive_partial_state_with_lockup_only() { + let no_swap_preimage = None; + let swap_preimage = Some("cccc".to_string()); let recovered_data = RecoveredOnchainDataSend { lockup_tx_id: Some(create_lbtc_history_txid("1111", 100)), claim_tx_id: None, @@ -80,19 +84,30 @@ mod test { // Not expired yet - should be Pending assert_eq!( - recovered_data.derive_partial_state(false), + recovered_data.derive_partial_state(no_swap_preimage.clone(), false), Some(PaymentState::Pending) ); + // Not expired yet - should be Complete + assert_eq!( + recovered_data.derive_partial_state(swap_preimage.clone(), false), + Some(PaymentState::Complete) + ); // Expired - should be RefundPending assert_eq!( - recovered_data.derive_partial_state(true), + recovered_data.derive_partial_state(no_swap_preimage, true), Some(PaymentState::RefundPending) ); + // Expired - should be Complete + assert_eq!( + recovered_data.derive_partial_state(swap_preimage, true), + Some(PaymentState::Complete) + ); } #[sdk_macros::test_all] fn test_derive_partial_state_with_no_txs() { + let no_swap_preimage = None; let recovered_data = RecoveredOnchainDataSend { lockup_tx_id: None, claim_tx_id: None, @@ -101,11 +116,14 @@ mod test { }; // Not expired yet - should return None because we can't determine the state - assert_eq!(recovered_data.derive_partial_state(false), None); + assert_eq!( + recovered_data.derive_partial_state(no_swap_preimage.clone(), false), + None + ); // Expired - should be Failed assert_eq!( - recovered_data.derive_partial_state(true), + recovered_data.derive_partial_state(no_swap_preimage, true), Some(PaymentState::Failed) ); } @@ -113,6 +131,7 @@ mod test { #[sdk_macros::test_all] fn test_derive_partial_state_with_lockup_claim_refund() { // This is an edge case where both claim and refund txs exist + let swap_preimage = Some("cccc".to_string()); let recovered_data = RecoveredOnchainDataSend { lockup_tx_id: Some(create_lbtc_history_txid("1111", 100)), claim_tx_id: Some(create_lbtc_history_txid("2222", 101)), @@ -122,11 +141,11 @@ mod test { // Complete state should take precedence over refund assert_eq!( - recovered_data.derive_partial_state(false), + recovered_data.derive_partial_state(swap_preimage.clone(), false), Some(PaymentState::Complete) ); assert_eq!( - recovered_data.derive_partial_state(true), + recovered_data.derive_partial_state(swap_preimage, true), Some(PaymentState::Complete) ); } diff --git a/lib/core/src/recover/handlers/tests/handle_send_swap_tests_integration.rs b/lib/core/src/recover/handlers/tests/handle_send_swap_tests_integration.rs index 9d6b1cf..128febd 100644 --- a/lib/core/src/recover/handlers/tests/handle_send_swap_tests_integration.rs +++ b/lib/core/src/recover/handlers/tests/handle_send_swap_tests_integration.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod test { use crate::chain::liquid::MockLiquidChainService; + use crate::error::PaymentError; use crate::prelude::*; use crate::recover::handlers::tests::{ create_empty_lbtc_transaction, create_mock_lbtc_wallet_tx, @@ -84,7 +85,14 @@ mod test { #[sdk_macros::async_test_all] async fn test_recover_with_refund_tx() { // Setup mock data - let (mut send_swap, recovery_context) = setup_test_data(); + let (mut send_swap, mut recovery_context) = setup_test_data(); + + // Setup mock swapper + let mut swapper = MockSwapper::new(); + swapper + .expect_get_submarine_preimage() + .returning(move |_| Err(PaymentError::generic("No preimage available"))); + recovery_context.swapper = Arc::new(swapper); // Create a lockup tx let swap_script = send_swap.get_swap_script().unwrap(); @@ -127,7 +135,15 @@ mod test { #[sdk_macros::async_test_all] async fn test_recover_with_lockup_only() { // Setup mock data - let (mut send_swap, recovery_context) = setup_test_data(); + let (mut send_swap, mut recovery_context) = setup_test_data(); + + // Setup mock swapper + let preimage = "49666c97f6cea07fa5780c22ece1f0c9957caf1e3c37b9037b4f64dc6d09be7f"; // base64 of "somepreimage1234567890" + let mut swapper = MockSwapper::new(); + swapper + .expect_get_submarine_preimage() + .returning(move |_| Ok(preimage.to_string())); + recovery_context.swapper = Arc::new(swapper); // Create a lockup tx let swap_script = send_swap.get_swap_script().unwrap(); @@ -151,7 +167,7 @@ mod test { // Verify results assert!(result.is_ok()); - assert_eq!(send_swap.state, PaymentState::Pending); // Not expired -> Pending + assert_eq!(send_swap.state, PaymentState::Complete); // Not expired -> Complete assert_eq!(send_swap.lockup_tx_id, Some(lockup_tx_id.to_string())); assert_eq!(send_swap.refund_tx_id, None); } @@ -162,6 +178,13 @@ mod test { // Setup mock data let (mut send_swap, mut recovery_context) = setup_test_data(); + // Setup mock swapper + let mut swapper = MockSwapper::new(); + swapper + .expect_get_submarine_preimage() + .returning(move |_| Err(PaymentError::generic("Swap expired"))); + recovery_context.swapper = Arc::new(swapper); + // Set tip height to make swap expired recovery_context.liquid_tip_height = send_swap.timeout_block_height as u32 + 10; @@ -196,7 +219,14 @@ mod test { #[sdk_macros::async_test_all] async fn test_recover_with_unconfirmed_refund() { // Setup mock data - let (mut send_swap, recovery_context) = setup_test_data(); + let (mut send_swap, mut recovery_context) = setup_test_data(); + + // Setup mock swapper + let mut swapper = MockSwapper::new(); + swapper + .expect_get_submarine_preimage() + .returning(move |_| Err(PaymentError::generic("No preimage available"))); + recovery_context.swapper = Arc::new(swapper); // Create a lockup tx let swap_script = send_swap.get_swap_script().unwrap(); @@ -300,7 +330,7 @@ mod test { preimage: None, payer_amount_sat: 100000, receiver_amount_sat: 95000, - pair_fees_json: r#"{"id":"BTC/BTC","rate":0.997,"limits":{"maximal":2000000,"minimal":10000,"maximalZeroConf":50000},"fees":{"percentage":0.5,"miner":200}}"#.to_string(), + pair_fees_json: r#"{"hash":"BTC/BTC","rate":0.997,"limits":{"maximal":2000000,"minimal":10000,"maximalZeroConf":50000,"minimalBatched":21},"fees":{"percentage":0.5,"minerFees":200}}"#.to_string(), create_response_json: r#"{"accept_zero_conf":true,"address":"lq1pqg8hsjkptr8u7l35ctx5yn4dpwufkjxt7d24zuj5ddahnn7jaduh8r6celry8kn9xrkgwchrrx2madlemf0u27pnmjar4d4k5wvtem8kfl7ru56w94sv","bip21":"liquidnetwork:lq1pqg8hsjkptr8u7l35ctx5yn4dpwufkjxt7d24zuj5ddahnn7jaduh8r6celry8kn9xrkgwchrrx2madlemf0u27pnmjar4d4k5wvtem8kfl7ru56w94sv?amount=0.00001015&label=Send%20to%20BTC%20lightning&assetid=6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d","claim_public_key":"0381b8583fe95488b961d12836102b1869b241972e571bd44a933d273b12a0d123","expected_amount":1015,"referral_id":"breez-sdk","swap_tree":{"claim_leaf":{"output":"a914cea2d1aa5af00fb688727b0054de58ecf45e948f882081b8583fe95488b961d12836102b1869b241972e571bd44a933d273b12a0d123ac","version":196},"refund_leaf":{"output":"20a668381222ff9076ca6d5f5b098b501331f07d5065f1dc0e0f217cc493359e69ad03b12432b1","version":196}},"timeout_block_height":3286193,"blinding_key":"73332603e5d438ddb3b12c16c7271c9f98658c77257cbb06639d05773aa1fec3"}"#.to_string(), lockup_tx_id: None, refund_address: None, diff --git a/lib/core/src/sdk.rs b/lib/core/src/sdk.rs index 30f1c36..47e68dd 100644 --- a/lib/core/src/sdk.rs +++ b/lib/core/src/sdk.rs @@ -2000,7 +2000,7 @@ impl LiquidSdk { Ok(LightningPaymentLimitsResponse { send: Limits { - min_sat: send_limits.minimal, + min_sat: send_limits.minimal_batched.unwrap_or(send_limits.minimal), max_sat: send_limits.maximal, max_zero_conf_sat: send_limits.maximal_zero_conf, }, diff --git a/lib/core/src/send_swap.rs b/lib/core/src/send_swap.rs index 1aeea3e..4603323 100644 --- a/lib/core/src/send_swap.rs +++ b/lib/core/src/send_swap.rs @@ -2,10 +2,11 @@ use std::str::FromStr; use std::time::Duration; use anyhow::{anyhow, Result}; +use boltz_client::boltz::SubmarineClaimTxResponse; use boltz_client::swaps::boltz; use boltz_client::swaps::{boltz::CreateSubmarineResponse, boltz::SubSwapStates}; use futures_util::TryFutureExt; -use log::{debug, error, info, warn}; +use log::{debug, info, warn}; use lwk_wollet::elements::{LockTime, Transaction}; use lwk_wollet::hashes::{sha256, Hash}; use sdk_common::prelude::{AesSuccessActionDataResult, SuccessAction, SuccessActionProcessed}; @@ -93,14 +94,35 @@ impl SendSwapHandler { Ok(()) } - // Boltz has detected the lockup in the mempool, we can speed up - // the claim by doing so cooperatively + // Boltz has detected the lockup in the mempool. If the swap is not to be batched + // we can speed up the claim by doing so cooperatively. SubSwapStates::TransactionClaimPending => { if swap.metadata.is_local { - self.cooperate_claim(&swap).await.map_err(|e| { - error!("Could not cooperate Send Swap {id} claim: {e}"); - anyhow!("Could not post claim details. Err: {e:?}") - })?; + let preimage = match self.swapper.get_send_claim_tx_details(&swap).await { + Ok(claim_tx_response) => { + match self.cooperate_claim(&swap, claim_tx_response.clone()).await { + Ok(_) => Some(claim_tx_response.preimage), + Err(e) => { + warn!("Could not cooperate Send Swap {id} claim: {e:?}"); + None + } + } + } + Err(e) => { + warn!("Could not get claim tx details for Send Swap {id}: {e:?}"); + None + } + }; + let preimage = match preimage { + Some(preimage) => preimage, + None => { + let preimage = self.swapper.get_submarine_preimage(&swap.id).await?; + utils::verify_payment_hash(&preimage, &swap.invoice)?; + info!("Fetched Send Swap {id} preimage cooperatively"); + preimage + } + }; + self.update_swap_info(&swap.id, Complete, Some(&preimage), None, None)?; } Ok(()) @@ -359,7 +381,11 @@ impl SendSwapHandler { Ok(()) } - async fn cooperate_claim(&self, send_swap: &SendSwap) -> Result<(), PaymentError> { + async fn cooperate_claim( + &self, + send_swap: &SendSwap, + claim_tx_response: SubmarineClaimTxResponse, + ) -> Result<(), PaymentError> { debug!( "Claim is pending for Send Swap {}. Initiating cooperative claim", &send_swap.id @@ -375,16 +401,8 @@ impl SendSwapHandler { } }; - let claim_tx_details = self.swapper.get_send_claim_tx_details(send_swap).await?; - self.update_swap_info( - &send_swap.id, - Complete, - Some(&claim_tx_details.preimage), - None, - None, - )?; self.swapper - .claim_send_swap_cooperative(send_swap, claim_tx_details, &refund_address) + .claim_send_swap_cooperative(send_swap, claim_tx_response, &refund_address) .await?; Ok(()) } diff --git a/lib/core/src/swapper/boltz/mod.rs b/lib/core/src/swapper/boltz/mod.rs index b8b9228..3c74889 100644 --- a/lib/core/src/swapper/boltz/mod.rs +++ b/lib/core/src/swapper/boltz/mod.rs @@ -304,8 +304,6 @@ impl Swapper for BoltzSwapper

{ .new_lbtc_refund_wrapper(&Swap::Send(swap.clone()), refund_address) .await?; - self.validate_send_swap_preimage(swap_id, &swap.invoice, &claim_tx_response.preimage)?; - let (partial_sig, pub_nonce) = refund_tx_wrapper.partial_sign( &keypair, &claim_tx_response.pub_nonce, @@ -317,7 +315,7 @@ impl Swapper for BoltzSwapper

{ .inner .post_submarine_claim_tx_details(&swap_id.to_string(), pub_nonce, partial_sig) .await?; - info!("Successfully sent claim details for swap-in {swap_id}"); + info!("Successfully cooperatively claimed Send Swap {swap_id}"); Ok(()) } diff --git a/lib/core/src/test_utils/persist.rs b/lib/core/src/test_utils/persist.rs index 292cefe..c74b442 100644 --- a/lib/core/src/test_utils/persist.rs +++ b/lib/core/src/test_utils/persist.rs @@ -57,7 +57,8 @@ pub fn new_send_swap( "limits": { "maximal": 25000000, "minimal": 1000, - "maximalZeroConf": 100000 + "maximalZeroConf": 100000, + "minimalBatched": 21 }, "fees": { "percentage": 0.1, diff --git a/lib/core/src/test_utils/swapper.rs b/lib/core/src/test_utils/swapper.rs index c6c0e6f..0441a23 100644 --- a/lib/core/src/test_utils/swapper.rs +++ b/lib/core/src/test_utils/swapper.rs @@ -191,7 +191,7 @@ impl Swapper for MockSwapper { maximal: 25_000_000, minimal: 1_000, maximal_zero_conf: 250_000, - minimal_batched: None, + minimal_batched: Some(21), }, fees: SubmarineFees { percentage: 0.1, diff --git a/regtest/boltz b/regtest/boltz index 7d0123c..9036dcb 160000 --- a/regtest/boltz +++ b/regtest/boltz @@ -1 +1 @@ -Subproject commit 7d0123c68e00f883bc436a7ca50396ce70ff3632 +Subproject commit 9036dcb31ed743114ced6ff1d8d4b7dcbafe2520