breez_sdk_spark/chain/
rest_client.rs

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