Repository: breez/breez-sdk-liquid (language: python)

Issue:
Zero-amount swap fails if unconfirmed script balance is zero
If the SDK queries the lockup script balance too quickly after the Boltz status update it can be that it sees no balance and the swap fails.

```
[2024-12-13 08:46:47.763 DEBUG breez_sdk_liquid::chain_swap:413] Found lockup balance GetBalanceRes { confirmed: 0, unconfirmed: 0 }
[2024-12-13 08:46:47.763 ERROR breez_sdk_liquid::chain_swap:339] Failed to accept the quote for swap qoHLGaeTGGn5: Generic { err: “Lockup address has no confirmed or unconfirmed balance” }
```

Full log:
```
[2024-12-13 08:46:47.397 INFO breez_sdk_liquid::swapper::boltz::status_stream:119] Received text msg: “{\“event\“:\“update\“,\“channel\“:\“swap.update\“,\“args\“:[{\“id\“:\“qoHLGaeTGGn5\“,\“status\“:\“transaction.lockupFailed\“,\“failureReason\“:\“locked 50000 is more than expected 0\“,\“failureDetails\“:{\“expected\“:0,\“actual\“:50000}}],\“timestamp\“:\“1734076005181\“}”
[2024-12-13 08:46:47.400 INFO breez_sdk_liquid::chain_swap:202] Handling incoming Chain Swap transition to “transaction.lockupFailed” for swap qoHLGaeTGGn5
[2024-12-13 08:46:47.616 INFO breez_sdk_liquid::chain_swap:384] Got quote of 49018 sat for swap qoHLGaeTGGn5
[2024-12-13 08:46:47.616 DEBUG breez_sdk_liquid::chain_swap:397] Validating ChainSwap { id: “qoHLGaeTGGn5”, direction: Incoming, claim_address: None, lockup_address: “bc1pqjf84wkwchqmwp44yexzr09ygnehp7fpeuwcqtnjyd3g47y7pg8shp6v7a”, timeout_block_height: 874706, preimage: “475fb283da681ef407392426d999a600f7da4b4a7f9ecfd4f5b61b50efa8b878”, description: Some(“Bitcoin transfer”), payer_amount_sat: 0, receiver_amount_sat: 0, claim_fees_sat: 14, pair_fees_json: “{\“hash\“:\“70979521c3bcd465364d8e1d0e3282409f38a36316b6508d915a3fa329263810\“,\“rate\“:1.0,\“limits\“:{\“maximal\“:25000000,\“minimal\“:50000,\“maximalZeroConf\“:0},\“fees\“:{\“percentage\“:0.1,\“minerFees\“:{\“server\“:932,\“user\“:{\“lockup\“:924,\“claim\“:14}}}}“, accept_zero_conf: false, create_response_json: “{\“claim_details\“:{\“swapTree\“:{\“claimLeaf\“:{\“output\“:\“82012088a914fb6fc47a3dc6223c735c68a9985d4312fe17c2568820ec79edfe1a280b0aaca2c9851d809c923aa44f7bd4f50d7a3548e010f031c868ac\“,\“version\“:196},\“refundLeaf\“:{\“output\“:\“20f1bd0278cce7ea1e482545211585f5100b604951695cefc4ed1e1c7aea280280ad036d4030b1\“,\“version\“:196}},\“lockupAddress\“:\“lq1pqg2gmg0jyhl782deke7qas7jv4xyxqnyc4q7q9unqdf4hyy3s260x2rhaj6dwhe26zy23euxrms6gt4ee7rrgaenw3w960y6zjjsrnrfqk6ef0vz9q6a\“,\“serverPublicKey\“:\“02f1bd0278cce7ea1e482545211585f5100b604951695cefc4ed1e1c7aea280280\“,\“timeoutBlockHeight\“:3162221,\“amount\“:0,\“blindingKey\“:\“e6b6029ea97dcf60d587e67184e751403209ed373d824eb377c4f5e85c578296\“},\“lockup_details\“:{\“swapTree\“:{\“claimLeaf\“:{\“output\“:\“82012088a914fb6fc47a3dc6223c735c68a9985d4312fe17c256882053cded06972fc81e820357cfec8b3dfee02b8e333d41c8a871b164d3fcca7584ac\“,\“version\“:192},\“refundLeaf\“:{\“output\“:\“200489c19f4d9528915ac6c350ea070c73ac365aa0f15c98d3de83dc062061394fad03d2580db1\“,\“version\“:192}},\“lockupAddress\“:\“bc1pqjf84wkwchqmwp44yexzr09ygnehp7fpeuwcqtnjyd3g47y7pg8shp6v7a\“,\“serverPublicKey\“:\“0353cded06972fc81e820357cfec8b3dfee02b8e333d41c8a871b164d3fcca7584\“,\“timeoutBlockHeight\“:874706,\“amount\“:0,\“bip21\“:\“bitcoin:bc1pqjf84wkwchqmwp44yexzr09ygnehp7fpeuwcqtnjyd3g47y7pg8shp6v7a?label=Send%20to%20L-BTC%20address\“}}“, server_lockup_tx_id: None, user_lockup_tx_id: None, claim_tx_id: None, refund_tx_id: None, created_at: 1734075367, state: Created, claim_private_key: “30b4826b1f816bfa63f2627244286d19c8a3df2e4f87afb3f7885d1b1598e790”, refund_private_key: “113efd5cc0a8e1c6c9963b39a265b28bfe4d47057d9c3cf9f26aae797c682ca9" }
[2024-12-13 08:46:47.618 INFO boltz_client::swaps::bitcoin:361] Taproot creation and verification success!
[2024-12-13 08:46:47.763 DEBUG breez_sdk_liquid::chain_swap:413] Found lockup balance GetBalanceRes { confirmed: 0, unconfirmed: 0 }
[2024-12-13 08:46:47.763 ERROR breez_sdk_liquid::chain_swap:339] Failed to accept the quote for swap qoHLGaeTGGn5: Generic { err: “Lockup address has no confirmed or unconfirmed balance” }
[2024-12-13 08:46:47.763 WARN breez_sdk_liquid::chain_swap:345] Chain Swap qoHLGaeTGGn5 is in an unrecoverable state: TransactionLockupFailed
[2024-12-13 08:46:47.765 INFO boltz_client::swaps::bitcoin:361] Taproot creation and verification success!
[2024-12-13 08:46:47.765 INFO breez_sdk_liquid::chain::bitcoin:163] Fetching script history for 84be16b7c7a468b9e1c10a8e115bfb06e6241307b5e25f3fd404f7ae0e15df6e
[2024-12-13 08:46:47.900 INFO breez_sdk_liquid::chain::bitcoin:172] Script history for 84be16b7c7a468b9e1c10a8e115bfb06e6241307b5e25f3fd404f7ae0e15df6e got zero transactions, retrying in 1 seconds...
[2024-12-13 08:46:49.040 INFO breez_sdk_liquid::chain_swap:763] Updating Chain swap ChainSwapUpdate { swap_id: “qoHLGaeTGGn5", to_state: Pending, server_lockup_tx_id: None, user_lockup_tx_id: Some(“b5e2b481e916830aac53c24f765ff9862b29a62b125060af60a7135444caf97a”), claim_address: None, claim_tx_id: None, refund_tx_id: None }
[2024-12-13 08:46:49.044 INFO breez_sdk_liquid::chain_swap:348] Chain Swap qoHLGaeTGGn5 user lockup tx was broadcast. Setting the swap to refundable.
[2024-12-13 08:46:49.044 INFO breez_sdk_liquid::chain_swap:763] Updating Chain swap ChainSwapUpdate { swap_id: “qoHLGaeTGGn5", to_state: Refundable, server_lockup_tx_id: None, user_lockup_tx_id: None, claim_address: None, claim_tx_id: None, refund_tx_id: None }
[2024-12-13 08:46:49.048 INFO breez_sdk_liquid::sdk:435] Successfully handled Chain Swap qoHLGaeTGGn5 update
```


Discussion / hints:

Fix patch:
diff --git a/cli/Cargo.lock b/cli/Cargo.lock
index aa3018e9c..e12051610 100644
--- a/cli/Cargo.lock
+++ b/cli/Cargo.lock
@@ -3046,7 +3046,7 @@ dependencies = [
 [[package]]
 name = "sdk-common"
 version = "0.6.2"
-source = "git+https://github.com/breez/breez-sdk?rev=238245bd34be15727493d7d0c625c6ae55f2a845#238245bd34be15727493d7d0c625c6ae55f2a845"
+source = "git+https://github.com/breez/breez-sdk?rev=e537feb8ed134bc3c7af5196e10a0a189dd36ac7#e537feb8ed134bc3c7af5196e10a0a189dd36ac7"
 dependencies = [
  "aes 0.8.4",
  "anyhow",
diff --git a/lib/core/src/chain/bitcoin.rs b/lib/core/src/chain/bitcoin.rs
index cf084209c..91452b04b 100644
--- a/lib/core/src/chain/bitcoin.rs
+++ b/lib/core/src/chain/bitcoin.rs
@@ -1,4 +1,3 @@
-use std::thread;
 use std::time::Duration;
 
 use anyhow::{anyhow, Result};
@@ -56,6 +55,13 @@ pub trait BitcoinChainService: Send + Sync {
     /// Return the confirmed and unconfirmed balances of a list of script hashes
     fn scripts_get_balance(&self, scripts: &[&Script]) -> Result<Vec<GetBalanceRes>>;
 
+    /// Return the confirmed and unconfirmed balances of a script hash
+    async fn script_get_balance_with_retry(
+        &self,
+        script: &Script,
+        retries: u64,
+    ) -> Result<GetBalanceRes>;
+
     /// Verify that a transaction appears in the address script history
     async fn verify_tx(
         &self,
@@ -173,7 +179,7 @@ impl BitcoinChainService for HybridBitcoinChainService {
                         "Script history for {} got zero transactions, retrying in {} seconds...",
                         script_hash, retry
                     );
-                    thread::sleep(Duration::from_secs(retry));
+                    tokio::time::sleep(Duration::from_secs(retry)).await;
                 }
                 false => break,
             }
@@ -214,6 +220,39 @@ impl BitcoinChainService for HybridBitcoinChainService {
         Ok(self.client.batch_script_get_balance(scripts)?)
     }
 
+    async fn script_get_balance_with_retry(
+        &self,
+        script: &Script,
+        retries: u64,
+    ) -> Result<GetBalanceRes> {
+        let script_hash = sha256::Hash::hash(script.as_bytes()).to_hex();
+        info!("Fetching script balance for {}", script_hash);
+        let mut script_balance = GetBalanceRes {
+            confirmed: 0,
+            unconfirmed: 0,
+        };
+
+        let mut retry = 0;
+        while retry <= retries {
+            script_balance = self.script_get_balance(script)?;
+            match script_balance {
+                GetBalanceRes {
+                    confirmed: 0,
+                    unconfirmed: 0,
+                } => {
+                    retry += 1;
+                    info!(
+                        "Got zero balance for script {}, retrying in {} seconds...",
+                        script_hash, retry
+                    );
+                    tokio::time::sleep(Duration::from_secs(retry)).await;
+                }
+                _ => break,
+            }
+        }
+        Ok(script_balance)
+    }
+
     async fn verify_tx(
         &self,
         address: &Address,
diff --git a/lib/core/src/chain/liquid.rs b/lib/core/src/chain/liquid.rs
index 42dbb21e0..03e1f617a 100644
--- a/lib/core/src/chain/liquid.rs
+++ b/lib/core/src/chain/liquid.rs
@@ -1,4 +1,4 @@
-use std::{str::FromStr, thread, time::Duration};
+use std::{str::FromStr, time::Duration};
 
 use anyhow::{anyhow, Result};
 use async_trait::async_trait;
@@ -192,7 +192,7 @@ impl LiquidChainService for HybridLiquidChainService {
                     retry += 1;
                     info!("Script history for {script_hash} is empty, retrying in 1 second... ({retry} of {retries})");
                     // Waiting 1s between retries, so we detect the new tx as soon as possible
-                    thread::sleep(Duration::from_secs(1));
+                    tokio::time::sleep(Duration::from_secs(1)).await;
                 }
                 false => break,
             }
@@ -290,7 +290,7 @@ async fn get_with_retry(url: &str, retries: usize) -> Result<Response> {
             }
             let secs = 1 << attempt;
 
-            thread::sleep(Duration::from_secs(secs));
+            tokio::time::sleep(Duration::from_secs(secs)).await;
         }
     }
 }
diff --git a/lib/core/src/chain_swap.rs b/lib/core/src/chain_swap.rs
index 0ca54b7a6..f144d43a9 100644
--- a/lib/core/src/chain_swap.rs
+++ b/lib/core/src/chain_swap.rs
@@ -513,7 +513,8 @@ impl ChainSwapHandler {
             .bitcoin_chain_service
             .lock()
             .await
-            .script_get_balance(script_pubkey.as_script())?;
+            .script_get_balance_with_retry(script_pubkey.as_script(), 10)
+            .await?;
         debug!("Found lockup balance {script_balance:?}");
         let user_lockup_amount_sat = match script_balance.confirmed > 0 {
             true => script_balance.confirmed,


Test patch:
diff --git a/lib/core/src/test_utils/chain.rs b/lib/core/src/test_utils/chain.rs
index 275710af4..47b9275a2 100644
--- a/lib/core/src/test_utils/chain.rs
+++ b/lib/core/src/test_utils/chain.rs
@@ -193,6 +193,14 @@ impl BitcoinChainService for MockBitcoinChainService {
         unimplemented!()
     }
 
+    async fn script_get_balance_with_retry(
+        &self,
+        _script: &boltz_client::bitcoin::Script,
+        _retries: u64,
+    ) -> Result<electrum_client::GetBalanceRes> {
+        unimplemented!()
+    }
+
     async fn verify_tx(
         &self,
         _address: &boltz_client::Address,