breez_sdk_spark/chain/
rest_client.rs

1use base64::{Engine as _, engine::general_purpose};
2use bitcoin::{Address, address::NetworkUnchecked};
3use breez_sdk_common::rest::RestClient as CommonRestClient;
4use breez_sdk_common::{error::ServiceConnectivityError, rest::RestResponse};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::time::Duration;
8use tokio_with_wasm::alias as tokio;
9use tracing::info;
10
11use crate::chain::RecommendedFees;
12use crate::{
13    Network,
14    chain::{ChainServiceError, Utxo},
15};
16
17use super::BitcoinChainService;
18
19pub const RETRYABLE_ERROR_CODES: [u16; 3] = [
20    429, // TOO_MANY_REQUESTS
21    500, // INTERNAL_SERVER_ERROR
22    503, // SERVICE_UNAVAILABLE
23];
24
25/// Base backoff in milliseconds.
26const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256);
27
28#[derive(Serialize, Deserialize, Clone)]
29struct TxInfo {
30    txid: String,
31    status: super::TxStatus,
32}
33
34pub struct BasicAuth {
35    username: String,
36    password: String,
37}
38
39impl BasicAuth {
40    pub fn new(username: String, password: String) -> Self {
41        Self { username, password }
42    }
43}
44
45pub struct RestClientChainService {
46    base_url: String,
47    network: Network,
48    client: Box<dyn breez_sdk_common::rest::RestClient>,
49    max_retries: usize,
50    basic_auth: Option<BasicAuth>,
51    api_type: ChainApiType,
52}
53
54#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
55pub enum ChainApiType {
56    Esplora,
57    MempoolSpace,
58}
59
60#[derive(Deserialize)]
61#[serde(rename_all = "camelCase")]
62struct MempoolSpaceRecommendedFeesResponse {
63    fastest_fee: f64,
64    half_hour_fee: f64,
65    hour_fee: f64,
66    economy_fee: f64,
67    minimum_fee: f64,
68}
69
70impl From<MempoolSpaceRecommendedFeesResponse> for RecommendedFees {
71    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
72    fn from(response: MempoolSpaceRecommendedFeesResponse) -> Self {
73        Self {
74            fastest_fee: response.fastest_fee.ceil() as u64,
75            half_hour_fee: response.half_hour_fee.ceil() as u64,
76            hour_fee: response.hour_fee.ceil() as u64,
77            economy_fee: response.economy_fee.ceil() as u64,
78            minimum_fee: response.minimum_fee.ceil() as u64,
79        }
80    }
81}
82
83impl RestClientChainService {
84    pub fn new(
85        base_url: String,
86        network: Network,
87        max_retries: usize,
88        rest_client: Box<dyn CommonRestClient>,
89        basic_auth: Option<BasicAuth>,
90        api_type: ChainApiType,
91    ) -> Self {
92        Self {
93            base_url,
94            network,
95            client: rest_client,
96            max_retries,
97            basic_auth,
98            api_type,
99        }
100    }
101
102    async fn get_response_json<T: serde::de::DeserializeOwned>(
103        &self,
104        path: &str,
105    ) -> Result<T, ChainServiceError> {
106        let url = format!("{}{}", self.base_url, path);
107        info!("Fetching response json from {}", url);
108        let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
109
110        let response: T = serde_json::from_str(&response)
111            .map_err(|e| ChainServiceError::Generic(e.to_string()))?;
112
113        Ok(response)
114    }
115
116    async fn get_response_text(&self, path: &str) -> Result<String, ChainServiceError> {
117        let url = format!("{}{}", self.base_url, path);
118        info!("Fetching response text from {}", url);
119        let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
120        Ok(response)
121    }
122
123    async fn get_with_retry(
124        &self,
125        url: &str,
126        client: &dyn CommonRestClient,
127    ) -> Result<(String, u16), ChainServiceError> {
128        let mut delay = BASE_BACKOFF_MILLIS;
129        let mut attempts = 0;
130
131        loop {
132            let mut headers: Option<HashMap<String, String>> = None;
133            if let Some(basic_auth) = &self.basic_auth {
134                let auth_string = format!("{}:{}", basic_auth.username, basic_auth.password);
135                let encoded_auth = general_purpose::STANDARD.encode(auth_string.as_bytes());
136
137                headers = Some(
138                    vec![("Authorization".to_string(), format!("Basic {encoded_auth}"))]
139                        .into_iter()
140                        .collect(),
141                );
142            }
143
144            let RestResponse { body, status } =
145                client.get_request(url.to_string(), headers).await?;
146            match status {
147                status if attempts < self.max_retries && is_status_retryable(status) => {
148                    tokio::time::sleep(delay).await;
149                    attempts = attempts.saturating_add(1);
150                    delay = delay.saturating_mul(2);
151                }
152                _ => {
153                    if !(200..300).contains(&status) {
154                        return Err(ServiceConnectivityError::Status { status, body }.into());
155                    }
156                    return Ok((body, status));
157                }
158            }
159        }
160    }
161
162    async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
163        let mut headers: HashMap<String, String> = HashMap::new();
164        headers.insert("Content-Type".to_string(), "text/plain".to_string());
165        if let Some(basic_auth) = &self.basic_auth {
166            let auth_string = format!("{}:{}", basic_auth.username, basic_auth.password);
167            let encoded_auth = general_purpose::STANDARD.encode(auth_string.as_bytes());
168            headers.insert("Authorization".to_string(), format!("Basic {encoded_auth}"));
169        }
170        info!(
171            "Posting to {} with body {} and headers {:?}",
172            url,
173            body.clone().unwrap_or_default(),
174            headers
175        );
176        let RestResponse { body, status } = self
177            .client
178            .post_request(url.to_string(), Some(headers), body)
179            .await?;
180        if !(200..300).contains(&status) {
181            return Err(ServiceConnectivityError::Status { status, body }.into());
182        }
183
184        Ok(body)
185    }
186
187    async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
188        let fee_map = self
189            .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
190            .await?;
191        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
192        let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
193
194        Ok(RecommendedFees {
195            fastest_fee: get_fees(&1),
196            half_hour_fee: get_fees(&3),
197            hour_fee: get_fees(&6),
198            economy_fee: get_fees(&25),
199            minimum_fee: get_fees(&1008),
200        })
201    }
202
203    async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
204        let response = self
205            .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
206            .await?;
207        Ok(response.into())
208    }
209}
210
211#[macros::async_trait]
212impl BitcoinChainService for RestClientChainService {
213    async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
214        let address = address
215            .parse::<Address<NetworkUnchecked>>()?
216            .require_network(self.network.into())?;
217
218        let utxos = self
219            .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
220            .await?;
221
222        Ok(utxos)
223    }
224
225    async fn get_transaction_status(
226        &self,
227        txid: String,
228    ) -> Result<super::TxStatus, ChainServiceError> {
229        let tx_info = self
230            .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
231            .await?;
232        Ok(tx_info.status)
233    }
234
235    async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
236        let tx = self
237            .get_response_text(format!("/tx/{txid}/hex").as_str())
238            .await?;
239        Ok(tx)
240    }
241
242    async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
243        let url = format!("{}{}", self.base_url, "/tx");
244        self.post(&url, Some(tx)).await?;
245        Ok(())
246    }
247
248    async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
249        match self.api_type {
250            ChainApiType::Esplora => self.recommended_fees_esplora().await,
251            ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
252        }
253    }
254}
255
256fn is_status_retryable(status: u16) -> bool {
257    RETRYABLE_ERROR_CODES.contains(&status)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::Network;
264
265    use macros::async_test_all;
266
267    #[cfg(feature = "browser-tests")]
268    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
269
270    #[cfg(test)]
271    use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
272
273    #[async_test_all]
274    async fn test_get_address_utxos() {
275        // Mock JSON response from the actual API call
276        let mock_response = r#"[
277            {
278                "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
279                "vout": 3,
280                "status": {
281                    "confirmed": true,
282                    "block_height": 725850,
283                    "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
284                    "block_time": 1646382740
285                },
286                "value": 24201
287            },
288            {
289                "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
290                "vout": 0,
291                "status": {
292                    "confirmed": true,
293                    "block_height": 840719,
294                    "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
295                    "block_time": 1713994081
296                },
297                "value": 30236
298            },
299            {
300                "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
301                "vout": 74,
302                "status": {
303                    "confirmed": true,
304                    "block_height": 726892,
305                    "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
306                    "block_time": 1647033214
307                },
308                "value": 5155
309            },
310            {
311                "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
312                "vout": 0,
313                "status": {
314                    "confirmed": true,
315                    "block_height": 857808,
316                    "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
317                    "block_time": 1724272387
318                },
319                "value": 6127
320            },
321            {
322                "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
323                "vout": 126,
324                "status": {
325                    "confirmed": true,
326                    "block_height": 748177,
327                    "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
328                    "block_time": 1659763398
329                },
330                "value": 22190
331            }
332        ]"#;
333
334        let mock = MockRestClient::new();
335        mock.add_response(MockResponse::new(200, mock_response.to_string()));
336
337        // Create the service with the mock server URL
338        let service = RestClientChainService::new(
339            "http://localhost:8080".to_string(),
340            Network::Mainnet,
341            3,
342            Box::new(mock),
343            None,
344            ChainApiType::Esplora,
345        );
346
347        // Call the method under test
348        let mut result = service
349            .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
350            .await
351            .unwrap();
352
353        // Sort results by value for consistent testing
354        result.sort_by(|a, b| a.value.cmp(&b.value));
355
356        // Verify we got the expected number of UTXOs
357        assert_eq!(result.len(), 5);
358
359        // Verify the UTXOs are correctly parsed and sorted by value
360        assert_eq!(result[0].value, 5155); // Smallest value
361        assert_eq!(
362            result[0].txid,
363            "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
364        );
365        assert_eq!(result[0].vout, 74);
366        assert!(result[0].status.confirmed);
367        assert_eq!(result[0].status.block_height, Some(726_892));
368
369        assert_eq!(result[1].value, 6127);
370        assert_eq!(
371            result[1].txid,
372            "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
373        );
374
375        assert_eq!(result[2].value, 22190);
376        assert_eq!(result[3].value, 24201);
377        assert_eq!(result[4].value, 30236); // Largest value
378
379        // Verify all UTXOs are confirmed
380        for utxo in &result {
381            assert!(utxo.status.confirmed);
382            assert!(utxo.status.block_height.is_some());
383            assert!(utxo.status.block_time.is_some());
384        }
385    }
386}