Repository: breez/breez-sdk-liquid
Pull request #845: Switch Esplora client url to Breez server instance
Author: login: hydra-yse
Created: 2025-04-01T14:45:15Z | Merged: 2025-04-08T08:27:14Z

~Waiting for breez/server#67~
This PR:
- Adds API key check for Breez waterfalls url on all client types (chain, wallet, swapper)
- Changes the main Esplora url to Breez instance

## Notes
- Switching to [custom branch](https://github.com/hydra-yse/boltz-rust/tree/esplora-custom-client) until SatoshiPortal/boltz-rust#100 is merged


Diff:
diff --git a/lib/Cargo.lock b/lib/Cargo.lock
index ee28c6944..c21565f84 100644
--- a/lib/Cargo.lock
+++ b/lib/Cargo.lock
@@ -760,7 +760,7 @@ checksum = "829a082bd3761fde7476dc2ed85ca56c11628948460ece621e4f56fef5046567"
 [[package]]
 name = "boltz-client"
 version = "0.3.0"
-source = "git+https://github.com/SatoshiPortal/boltz-rust?rev=f78e159fe72e1c357e7830bc08d2b9e42a65362c#f78e159fe72e1c357e7830bc08d2b9e42a65362c"
+source = "git+https://github.com/hydra-yse/boltz-rust?rev=b54f181e218d#b54f181e218d6c7d44d7bfd3eabda3681006fbc0"
 dependencies = [
  "async-trait",
  "bip39",
@@ -3182,7 +3182,7 @@ dependencies = [
 [[package]]
 name = "macros"
 version = "0.0.0"
-source = "git+https://github.com/SatoshiPortal/boltz-rust?rev=f78e159fe72e1c357e7830bc08d2b9e42a65362c#f78e159fe72e1c357e7830bc08d2b9e42a65362c"
+source = "git+https://github.com/hydra-yse/boltz-rust?rev=b54f181e218d#b54f181e218d6c7d44d7bfd3eabda3681006fbc0"
 dependencies = [
  "proc-macro2",
  "quote",
diff --git a/lib/core/Cargo.toml b/lib/core/Cargo.toml
index c8b51035f..a526dc3c5 100644
--- a/lib/core/Cargo.toml
+++ b/lib/core/Cargo.toml
@@ -76,7 +76,7 @@ maybe-sync = { version = "0.1.1", features = ["sync"] }
 prost = "^0.11"
 tonic = { version = "^0.8", features = ["tls", "tls-webpki-roots"] }
 uuid = { version = "1.8.0", features = ["v4"] }
-boltz-client = { git = "https://github.com/SatoshiPortal/boltz-rust", rev = "f78e159fe72e1c357e7830bc08d2b9e42a65362c", features = ["electrum"] }
+boltz-client = { git = "https://github.com/hydra-yse/boltz-rust", rev = "b54f181e218d", features = ["electrum"] }
 rusqlite = { git = "https://github.com/Spxg/rusqlite", rev = "e36644127f31fa6e7ea0999b59432deb4a07f220", features = [
     "backup",
     "bundled",
@@ -95,7 +95,7 @@ tonic = { version = "0.12", default-features = false, features = [
     "codegen",
     "prost",
 ] }
-boltz-client = { git = "https://github.com/SatoshiPortal/boltz-rust", rev = "f78e159fe72e1c357e7830bc08d2b9e42a65362c" }
+boltz-client = { git = "https://github.com/hydra-yse/boltz-rust", rev = "b54f181e218d" }
 rusqlite = { git = "https://github.com/Spxg/rusqlite", rev = "e36644127f31fa6e7ea0999b59432deb4a07f220", features = [
     "backup",
     "bundled",
diff --git a/lib/core/src/chain/bitcoin/esplora.rs b/lib/core/src/chain/bitcoin/esplora.rs
index bc83c7dcc..a59091b6b 100644
--- a/lib/core/src/chain/bitcoin/esplora.rs
+++ b/lib/core/src/chain/bitcoin/esplora.rs
@@ -52,7 +52,6 @@ impl EsploraBitcoinChainService {
             .timeout(3)
             .max_retries(10)
             .build_async()?;
-
         let client = self.client.get_or_init(|| client);
         Ok(client)
     }
diff --git a/lib/core/src/chain/liquid/esplora.rs b/lib/core/src/chain/liquid/esplora.rs
index e77f850d0..639f805b3 100644
--- a/lib/core/src/chain/liquid/esplora.rs
+++ b/lib/core/src/chain/liquid/esplora.rs
@@ -1,16 +1,16 @@
 use std::{sync::OnceLock, time::Duration};
 
-use anyhow::{anyhow, Context as _, Result};
+use anyhow::{anyhow, bail, Context as _, Result};
 use tokio::sync::RwLock;
 use tokio_with_wasm::alias as tokio;
 
 use crate::{
     elements::{Address, OutPoint, Script, Transaction, Txid},
-    model::{BlockchainExplorer, Config, Utxo},
+    model::{BlockchainExplorer, Config, Utxo, BREEZ_LIQUID_ESPLORA_URL},
     utils,
 };
 
-use log::info;
+use log::{error, info};
 use lwk_wollet::{
     asyncr::EsploraClientBuilder, clients::asyncr::EsploraClient, elements::hex::FromHex as _,
 };
@@ -40,10 +40,23 @@ impl EsploraLiquidChainService {
             BlockchainExplorer::Esplora {
                 url,
                 use_waterfalls,
-            } => EsploraClientBuilder::new(url, self.config.network.into())
-                .timeout(3)
-                .waterfalls(*use_waterfalls)
-                .build(),
+            } => {
+                let mut builder = EsploraClientBuilder::new(url, self.config.network.into());
+                if url == BREEZ_LIQUID_ESPLORA_URL {
+                    match &self.config.breez_api_key {
+                        Some(api_key) => {
+                            builder = builder
+                                .header("authorization".to_string(), format!("Bearer {api_key}"));
+                        }
+                        None => {
+                            let err = "Cannot start Breez Esplora client: Breez API key is not set";
+                            error!("{err}");
+                            bail!(err)
+                        }
+                    };
+                }
+                builder.timeout(3).waterfalls(*use_waterfalls).build()
+            }
             #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
             BlockchainExplorer::Electrum { .. } => {
                 anyhow::bail!("Cannot start Liquid Esplora chain service without an Esplora url")
diff --git a/lib/core/src/model.rs b/lib/core/src/model.rs
index 01e29eee4..0642404bc 100644
--- a/lib/core/src/model.rs
+++ b/lib/core/src/model.rs
@@ -1,4 +1,4 @@
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, bail, Result};
 use bitcoin::{bip32, ScriptBuf};
 use boltz_client::{
     boltz::{ChainPair, BOLTZ_MAINNET_URL_V2, BOLTZ_REGTEST, BOLTZ_TESTNET_URL_V2},
@@ -40,6 +40,7 @@ use crate::{
 pub const LIQUID_FEE_RATE_SAT_PER_VBYTE: f64 = 0.1;
 pub const LIQUID_FEE_RATE_MSAT_PER_VBYTE: f32 = (LIQUID_FEE_RATE_SAT_PER_VBYTE * 1000.0) as f32;
 pub const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology";
+pub const BREEZ_LIQUID_ESPLORA_URL: &str = "https://lq1.breez.technology/liquid/api";
 
 const SIDESWAP_API_KEY: &str = "97fb6a1dfa37ee6656af92ef79675cc03b8ac4c52e04655f41edbd5af888dcc2";
 
@@ -128,7 +129,7 @@ impl Config {
     pub fn mainnet_esplora(breez_api_key: Option<String>) -> Self {
         Config {
             liquid_explorer: BlockchainExplorer::Esplora {
-                url: "https://waterfalls.liquidwebwallet.org/liquid/api".to_string(),
+                url: BREEZ_LIQUID_ESPLORA_URL.to_string(),
                 use_waterfalls: true,
             },
             bitcoin_explorer: BlockchainExplorer::Esplora {
@@ -314,15 +315,18 @@ impl Config {
         }
     }
 
-    pub(crate) fn liquid_chain_service(&self) -> Arc<dyn LiquidChainService> {
-        match self.liquid_explorer {
-            BlockchainExplorer::Esplora { .. } => {
-                Arc::new(EsploraLiquidChainService::new(self.clone()))
+    pub(crate) fn liquid_chain_service(&self) -> Result<Arc<dyn LiquidChainService>> {
+        match &self.liquid_explorer {
+            BlockchainExplorer::Esplora { url, .. } => {
+                if url == BREEZ_LIQUID_ESPLORA_URL && self.breez_api_key.is_none() {
+                    bail!("Cannot start the Breez Esplora chain service without providing a valid API key. See https://sdk-doc-liquid.breez.technology/guide/getting_started.html#api-key")
+                }
+                Ok(Arc::new(EsploraLiquidChainService::new(self.clone())))
             }
             #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
-            BlockchainExplorer::Electrum { .. } => Arc::new(
+            BlockchainExplorer::Electrum { .. } => Ok(Arc::new(
                 crate::chain::liquid::electrum::ElectrumLiquidChainService::new(self.clone()),
-            ),
+            )),
         }
     }
 
diff --git a/lib/core/src/sdk.rs b/lib/core/src/sdk.rs
index dbbc9e391..1c5c59e22 100644
--- a/lib/core/src/sdk.rs
+++ b/lib/core/src/sdk.rs
@@ -221,7 +221,7 @@ impl LiquidSdkBuilder {
         let liquid_chain_service: Arc<dyn LiquidChainService> =
             match self.liquid_chain_service.clone() {
                 Some(liquid_chain_service) => liquid_chain_service,
-                None => self.config.liquid_chain_service(),
+                None => self.config.liquid_chain_service()?,
             };
 
         let onchain_wallet: Arc<dyn OnchainWallet> = match self.onchain_wallet.clone() {
diff --git a/lib/core/src/swapper/boltz/client.rs b/lib/core/src/swapper/boltz/client.rs
index 768c992d9..76491cfc9 100644
--- a/lib/core/src/swapper/boltz/client.rs
+++ b/lib/core/src/swapper/boltz/client.rs
@@ -1,6 +1,6 @@
 use crate::{
     bitcoin, elements,
-    model::{BlockchainExplorer, Config},
+    model::{BlockchainExplorer, Config, BREEZ_LIQUID_ESPLORA_URL},
 };
 use boltz_client::{
     error::Error,
@@ -9,7 +9,9 @@ use boltz_client::{
         BitcoinChain, BitcoinClient as BoltzBitcoinClient, LiquidChain,
         LiquidClient as BoltzLiquidClient,
     },
+    reqwest,
 };
+use log::error;
 use sdk_macros::async_trait;
 
 const BOLTZ_CONNECTION_TIMEOUT: u8 = 100;
@@ -37,7 +39,30 @@ impl LiquidClient {
                 ))
             }
             BlockchainExplorer::Esplora { url, .. } => {
-                Self::Esplora(Box::new(EsploraLiquidClient::new(
+                let mut builder = reqwest::Client::builder();
+                if url == BREEZ_LIQUID_ESPLORA_URL {
+                    match &config.breez_api_key {
+                        Some(api_key) => {
+                            let mut headers = reqwest::header::HeaderMap::new();
+                            let api_key = format!("Bearer {api_key}").parse().map_err(|err| {
+                                Error::Generic(format!("Could not set api key header: {err}"))
+                            })?;
+                            headers.insert(reqwest::header::AUTHORIZATION, api_key);
+                            builder = builder.default_headers(headers)
+                        }
+                        None => {
+                            let err = "Cannot start Breez Esplora client: Breez API key is not set";
+                            error!("{err}");
+                            return Err(Error::Generic(err.to_string()));
+                        }
+                    };
+                }
+                let client = builder.build().map_err(|err| {
+                    Error::Generic(format!("Could not initialize HTTP client: {err}"))
+                })?;
+
+                Self::Esplora(Box::new(EsploraLiquidClient::with_client(
+                    client,
                     config.network.into(),
                     url,
                     BOLTZ_CONNECTION_TIMEOUT as u64,
diff --git a/lib/core/src/test_utils/swapper.rs b/lib/core/src/test_utils/swapper.rs
index c8cc2718d..c6c0e6fe7 100644
--- a/lib/core/src/test_utils/swapper.rs
+++ b/lib/core/src/test_utils/swapper.rs
@@ -4,7 +4,7 @@ use boltz_client::{
         ChainFees, ChainMinerFees, ChainPair, ChainSwapDetails, CreateChainResponse,
         CreateReverseResponse, CreateSubmarineResponse, Leaf, PairLimits, PairMinerFees,
         ReverseFees, ReverseLimits, ReversePair, SubmarineClaimTxResponse, SubmarineFees,
-        SubmarinePair, SwapTree,
+        SubmarinePair, SubmarinePairLimits, SwapTree,
     },
     util::secrets::Preimage,
     Amount, PublicKey,
@@ -187,10 +187,11 @@ impl Swapper for MockSwapper {
         Ok(Some(SubmarinePair {
             hash: generate_random_string(10),
             rate: 0.0,
-            limits: PairLimits {
+            limits: SubmarinePairLimits {
                 maximal: 25_000_000,
                 minimal: 1_000,
                 maximal_zero_conf: 250_000,
+                minimal_batched: None,
             },
             fees: SubmarineFees {
                 percentage: 0.1,
diff --git a/lib/core/src/wallet.rs b/lib/core/src/wallet.rs
index ea09d1136..02111485b 100644
--- a/lib/core/src/wallet.rs
+++ b/lib/core/src/wallet.rs
@@ -2,9 +2,9 @@ use std::collections::HashMap;
 use std::io::Write;
 use std::str::FromStr;
 
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, bail, Result};
 use boltz_client::ElementsAddress;
-use log::{debug, info, warn};
+use log::{debug, error, info, warn};
 use lwk_common::Signer as LwkSigner;
 use lwk_common::{singlesig_desc, Singlesig};
 use lwk_wollet::asyncr::{EsploraClient, EsploraClientBuilder};
@@ -22,7 +22,7 @@ use sdk_common::lightning::util::message_signing::verify;
 use tokio::sync::Mutex;
 use web_time::Instant;
 
-use crate::model::{BlockchainExplorer, Signer};
+use crate::model::{BlockchainExplorer, Signer, BREEZ_LIQUID_ESPLORA_URL};
 use crate::persist::Persister;
 use crate::signer::SdkLwkSigner;
 use crate::{
@@ -133,12 +133,22 @@ impl WalletClient {
                 url,
                 use_waterfalls,
             } => {
-                let client = Box::new(
-                    EsploraClientBuilder::new(url, config.network.into())
-                        .timeout(3)
-                        .waterfalls(*use_waterfalls)
-                        .build(),
-                );
+                let waterfalls = *use_waterfalls;
+                let mut builder = EsploraClientBuilder::new(url, config.network.into());
+                if url == BREEZ_LIQUID_ESPLORA_URL {
+                    match &config.breez_api_key {
+                        Some(api_key) => {
+                            builder = builder
+                                .header("authorization".to_string(), format!("Bearer {api_key}"));
+                        }
+                        None => {
+                            let err = "Cannot start Breez Esplora client: Breez API key is not set";
+                            error!("{err}");
+                            bail!(err)
+                        }
+                    };
+                }
+                let client = Box::new(builder.timeout(3).waterfalls(waterfalls).build());
                 Ok(Self::Esplora(client))
             }
         }


Reviews:
user: login: roeierez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T15:08:44Z
user: login: roeierez
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-01T16:14:36Z
user: login: dangeross
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:23:39Z
user: login: hydra-yse
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T16:33:49Z
user: login: dangeross
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:58:01Z
user: login: danielgranhao
body: LGTM. Just a nit
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T17:14:53Z
user: login: roeierez
body: It seems that we don't send the headers in Esplora client that is passed to the swapper functions.
@hydra-yse  One way is to add an option to pass request::Client to boltz EsploraLiquidClient and configure the auth header there.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-05T16:23:19Z
user: login: dangeross
body: Tested send/receive, chain send/receive
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-08T06:39:24Z
user: login: roeierez
body: Tested and was able to claim. LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-08T06:41:06Z

Review comments:
diff_hunk: @@ -48,11 +48,20 @@ impl EsploraBitcoinChainService {
                 anyhow::bail!("Cannot start Bitcoin Esplora chain service without an Esplora url")
             }
         };
-        let client = Builder::new(esplora_url)
-            .timeout(3)
-            .max_retries(10)
-            .build_async()?;
+        let mut builder = Builder::new(esplora_url).timeout(3).max_retries(10);
 
+        if esplora_url == BREEZ_LIQUID_ESPLORA_URL {
path: lib/core/src/chain/bitcoin/esplora.rs
user: login: roeierez
body: Shouldn't the change be only for liquid esplora? Currently it seems that we change bitcoin esplora.
created_at: 2025-04-01T15:08:40Z
author_association: MEMBER
diff_hunk: @@ -40,10 +40,21 @@ impl EsploraLiquidChainService {
             BlockchainExplorer::Esplora {
                 url,
                 use_waterfalls,
-            } => EsploraClientBuilder::new(url, self.config.network.into())
-                .timeout(3)
-                .waterfalls(*use_waterfalls)
-                .build(),
+            } => {
+                let mut builder = EsploraClientBuilder::new(url, self.config.network.into());
+                if url == BREEZ_LIQUID_ESPLORA_URL {
+                    match &self.config.breez_api_key {
+                        Some(api_key) => {
+                            builder = builder
+                                .header("authorization".to_string(), format!("Bearer {api_key}"));
+                        }
+                        None => {
+                            warn!("Breez API key is not set with Breez Esplora URL: client will not work as expected");
path: lib/core/src/chain/liquid/esplora.rs
user: login: dangeross
body: Can we not bail here if there's no API key?
created_at: 2025-04-01T16:23:39Z
author_association: COLLABORATOR
diff_hunk: @@ -40,10 +40,21 @@ impl EsploraLiquidChainService {
             BlockchainExplorer::Esplora {
                 url,
                 use_waterfalls,
-            } => EsploraClientBuilder::new(url, self.config.network.into())
-                .timeout(3)
-                .waterfalls(*use_waterfalls)
-                .build(),
+            } => {
+                let mut builder = EsploraClientBuilder::new(url, self.config.network.into());
+                if url == BREEZ_LIQUID_ESPLORA_URL {
+                    match &self.config.breez_api_key {
+                        Some(api_key) => {
+                            builder = builder
+                                .header("authorization".to_string(), format!("Bearer {api_key}"));
+                        }
+                        None => {
+                            warn!("Breez API key is not set with Breez Esplora URL: client will not work as expected");
path: lib/core/src/chain/liquid/esplora.rs
user: login: hydra-yse
body: I've thought about it, I agree that bailing here is better to avoid unpredictable behavior. The idea was that some endpoints don't need the API key and therefore they will work just fine. But still, we don't want to attempt a request knowing that some will fail, so it's better to just cut it all off.
created_at: 2025-04-01T16:33:49Z
author_association: MEMBER
diff_hunk: @@ -40,10 +40,23 @@ impl EsploraLiquidChainService {
             BlockchainExplorer::Esplora {
                 url,
                 use_waterfalls,
-            } => EsploraClientBuilder::new(url, self.config.network.into())
-                .timeout(3)
-                .waterfalls(*use_waterfalls)
-                .build(),
+            } => {
+                let mut builder = EsploraClientBuilder::new(url, self.config.network.into());
+                if url == BREEZ_LIQUID_ESPLORA_URL {
+                    match &self.config.breez_api_key {
+                        Some(api_key) => {
+                            builder = builder
+                                .header("authorization".to_string(), format!("Bearer {api_key}"));
+                        }
+                        None => {
+                            let err = "Cannot start Breez Esplora client: Breez API key is not set";
+                            warn!("{err}");
path: lib/core/src/chain/liquid/esplora.rs
user: login: danielgranhao
body: Nit: do we need a log here? If so, `error!` may be more appropriate
created_at: 2025-04-01T17:14:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: dangeross
created_at: 2025-04-03T11:39:58Z
body: Seems like there are some warning when building wasm from the lwk changes:
```bash
warning: unused import: `std::collections::HashMap`
 --> /Users/rosssavage/Source/Projects/lwk/lwk_wollet/src/pegin.rs:5:5
  |
5 | use std::collections::HashMap;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: unused import: `Error`
 --> /Users/rosssavage/Source/Projects/lwk/lwk_wollet/src/pegin.rs:9:30
  |
9 | use crate::{ElementsNetwork, Error};
  |                              ^^^^^

warning: variable does not need to be mutable
   --> /Users/rosssavage/Source/Projects/lwk/lwk_wollet/src/clients/asyncr/esplora.rs:637:13
    |
637 |         let mut builder = reqwest::Client::builder().default_headers(headers);
    |             ----^^^^^^^
    |             |
    |             help: remove this `mut`
    |
    = note: `#[warn(unused_mut)]` on by default

warning: method `capabilities` is never used
   --> /Users/rosssavage/Source/Projects/lwk/lwk_wollet/src/clients/asyncr/esplora.rs:575:19
    |
52  | impl EsploraClient {
    | ------------------ method in this implementation
...
575 |     pub(crate) fn capabilities(&self) -> HashSet<Capability> {
    |                   ^^^^^^^^^^^^
    |
    = note: `#[warn(dead_code)]` on by default

warning: function `height_with_fed_peg_script` is never used
  --> /Users/rosssavage/Source/Projects/lwk/lwk_wollet/src/pegin.rs:14:4
   |
14 | fn height_with_fed_peg_script(network: ElementsNetwork, current_tip: u32) -> u32 {
   |    ^^^^^^^^^^^^^^^^^^^^^^^^^^

warning: methods `get_script_batch`, `heights`, `paths`, `txs`, `tip`, and `descriptor` are never used
  --> /Users/rosssavage/Source/Projects/lwk/lwk_wollet/src/wollet.rs:59:8
   |
58 | pub trait WolletState {
   |           ----------- methods in this trait
59 |     fn get_script_batch(
   |        ^^^^^^^^^^^^^^^^
...
70 |     fn heights(&self) -> &HashMap<Txid, Option<Height>>;
   |        ^^^^^^^
71 |     fn paths(&self) -> &HashMap<Script, (Chain, ChildNumber)>;
   |        ^^^^^
72 |     fn txs(&self) -> HashSet<Txid>;
   |        ^^^
73 |     fn tip(&self) -> (Height, BlockHash);
   |        ^^^
74 |     fn last_unused(&self) -> LastUnused; // TODO change to &LastUnused when possible
75 |     fn descriptor(&self) -> WolletDescriptor;
   |        ^^^^^^^^^^
```
author_association: COLLABORATOR
user: login: dangeross
created_at: 2025-04-03T14:12:48Z
body: ~I get some issues testing this against our endpoint for the wallet scan:~
```
 Failed to scan wallet: Generic { err: "Generic(\"404 page not found\\n\")" }
```
~I can CURL a request to /v2/waterfalls + header auth and it works~
This is resolved now
author_association: COLLABORATOR