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