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