Skip to main content

breez_sdk_spark/chain/
rest_client.rs

1use bitcoin::{Address, address::NetworkUnchecked};
2use platform_utils::tokio;
3use platform_utils::{
4    ContentType, HttpClient, HttpError, HttpResponse, add_basic_auth_header,
5    add_content_type_header,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::Duration;
11use tracing::info;
12
13use crate::chain::RecommendedFees;
14use crate::{
15    Network,
16    chain::{ChainServiceError, Utxo},
17};
18
19use super::BitcoinChainService;
20
21pub const RETRYABLE_ERROR_CODES: [u16; 3] = [
22    429, // TOO_MANY_REQUESTS
23    500, // INTERNAL_SERVER_ERROR
24    503, // SERVICE_UNAVAILABLE
25];
26
27/// Base backoff in milliseconds.
28const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256);
29
30#[derive(Serialize, Deserialize, Clone)]
31struct TxInfo {
32    txid: String,
33    status: super::TxStatus,
34}
35
36pub struct BasicAuth {
37    username: String,
38    password: String,
39}
40
41impl BasicAuth {
42    pub fn new(username: String, password: String) -> Self {
43        Self { username, password }
44    }
45}
46
47struct RestClientChainServiceInner {
48    base_url: String,
49    network: Network,
50    client: Arc<dyn HttpClient>,
51    max_retries: usize,
52    basic_auth: Option<BasicAuth>,
53    api_type: ChainApiType,
54}
55
56/// REST-backed [`BitcoinChainService`].
57///
58/// The trait is exported through `UniFFI` with `with_foreign`, which makes
59/// `UniFFI` re-wrap every `Arc<dyn BitcoinChainService>` that round-trips
60/// across the FFI boundary in a foreign-callback proxy — even when both
61/// sides are Rust in the same process. That proxy routes calls back into
62/// Rust via `UniFFI`'s `RustFuture`, which is polled outside the surrounding
63/// tokio runtime context, so `reqwest`'s `tokio::time::sleep` panics with
64/// "no reactor running".
65///
66/// To stay correct under round-tripping (e.g. shared-chain-service in a
67/// server-side harness that builds the service in Rust, hands it to a
68/// foreign-language integration, and passes it back into multiple SDK
69/// instances), we capture a [`tokio::runtime::Handle`] at construction
70/// time and dispatch each trait-method body onto it via
71/// [`tokio::runtime::Handle::spawn`]. The outer future we return to
72/// `UniFFI` is just a `JoinHandle` await — a channel-wakeup poll that
73/// needs no tokio context.
74pub struct RestClientChainService {
75    inner: Arc<RestClientChainServiceInner>,
76    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
77    runtime_handle: tokio::runtime::Handle,
78}
79
80#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
81#[derive(Clone, Copy, Debug)]
82pub enum ChainApiType {
83    Esplora,
84    MempoolSpace,
85}
86
87#[derive(Deserialize)]
88#[serde(rename_all = "camelCase")]
89struct MempoolSpaceRecommendedFeesResponse {
90    fastest_fee: f64,
91    half_hour_fee: f64,
92    hour_fee: f64,
93    economy_fee: f64,
94    minimum_fee: f64,
95}
96
97impl From<MempoolSpaceRecommendedFeesResponse> for RecommendedFees {
98    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
99    fn from(response: MempoolSpaceRecommendedFeesResponse) -> Self {
100        Self {
101            fastest_fee: response.fastest_fee.ceil() as u64,
102            half_hour_fee: response.half_hour_fee.ceil() as u64,
103            hour_fee: response.hour_fee.ceil() as u64,
104            economy_fee: response.economy_fee.ceil() as u64,
105            minimum_fee: response.minimum_fee.ceil() as u64,
106        }
107    }
108}
109
110impl RestClientChainService {
111    pub fn new(
112        base_url: String,
113        network: Network,
114        max_retries: usize,
115        http_client: Arc<dyn HttpClient>,
116        basic_auth: Option<BasicAuth>,
117        api_type: ChainApiType,
118    ) -> Self {
119        Self {
120            inner: Arc::new(RestClientChainServiceInner {
121                base_url,
122                network,
123                client: http_client,
124                max_retries,
125                basic_auth,
126                api_type,
127            }),
128            // Captured here so each trait-method body can re-enter the
129            // surrounding runtime even when invoked from a `UniFFI`
130            // foreign-callback proxy that polls outside any tokio context.
131            // Callers reach this constructor from within an async path
132            // (`new_rest_chain_service`, `SdkBuilder::build`, etc.), so
133            // `Handle::current()` is guaranteed to find a runtime.
134            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
135            runtime_handle: tokio::runtime::Handle::current(),
136        }
137    }
138
139    /// Runs `work` on the captured tokio runtime (non-WASM) or inline
140    /// (WASM, where there's no separate runtime to dispatch onto).
141    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
142    async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
143    where
144        F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut + Send + 'static,
145        Fut: std::future::Future<Output = Result<T, ChainServiceError>> + Send,
146        T: Send + 'static,
147    {
148        let inner = self.inner.clone();
149        self.runtime_handle
150            .spawn(async move { work(inner).await })
151            .await
152            .map_err(|e| ChainServiceError::Generic(format!("join error: {e}")))?
153    }
154
155    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
156    async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
157    where
158        F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut,
159        Fut: std::future::Future<Output = Result<T, ChainServiceError>>,
160    {
161        work(self.inner.clone()).await
162    }
163}
164
165impl RestClientChainServiceInner {
166    async fn get_response_json<T: serde::de::DeserializeOwned>(
167        &self,
168        path: &str,
169    ) -> Result<T, ChainServiceError> {
170        let url = format!("{}{}", self.base_url, path);
171        info!("Fetching response json from {}", url);
172        let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
173
174        let response: T = serde_json::from_str(&response)
175            .map_err(|e| ChainServiceError::Generic(e.to_string()))?;
176
177        Ok(response)
178    }
179
180    async fn get_response_text(&self, path: &str) -> Result<String, ChainServiceError> {
181        let url = format!("{}{}", self.base_url, path);
182        info!("Fetching response text from {}", url);
183        let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
184        Ok(response)
185    }
186
187    async fn get_with_retry(
188        &self,
189        url: &str,
190        client: &dyn HttpClient,
191    ) -> Result<(String, u16), ChainServiceError> {
192        let mut delay = BASE_BACKOFF_MILLIS;
193        let mut attempts = 0;
194
195        loop {
196            let mut headers = HashMap::new();
197            if let Some(basic_auth) = &self.basic_auth {
198                add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
199            }
200
201            let HttpResponse { body, status, .. } =
202                client.get(url.to_string(), Some(headers)).await?;
203            match status {
204                status if attempts < self.max_retries && is_status_retryable(status) => {
205                    tokio::time::sleep(delay).await;
206                    attempts = attempts.saturating_add(1);
207                    delay = delay.saturating_mul(2);
208                }
209                _ => {
210                    if !(200..300).contains(&status) {
211                        return Err(HttpError::Status { status, body }.into());
212                    }
213                    return Ok((body, status));
214                }
215            }
216        }
217    }
218
219    async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
220        let mut headers: HashMap<String, String> = HashMap::new();
221        add_content_type_header(&mut headers, ContentType::TextPlain);
222        if let Some(basic_auth) = &self.basic_auth {
223            add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
224        }
225        info!(
226            "Posting to {} with body {} and headers {:?}",
227            url,
228            body.clone().unwrap_or_default(),
229            headers
230        );
231        let HttpResponse { body, status, .. } = self
232            .client
233            .post(url.to_string(), Some(headers), body)
234            .await?;
235        if !(200..300).contains(&status) {
236            return Err(HttpError::Status { status, body }.into());
237        }
238
239        Ok(body)
240    }
241
242    async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
243        let fee_map = self
244            .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
245            .await?;
246        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
247        let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
248
249        Ok(RecommendedFees {
250            fastest_fee: get_fees(&1),
251            half_hour_fee: get_fees(&3),
252            hour_fee: get_fees(&6),
253            economy_fee: get_fees(&25),
254            minimum_fee: get_fees(&1008),
255        })
256    }
257
258    async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
259        let response = self
260            .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
261            .await?;
262        Ok(response.into())
263    }
264
265    // ---- BitcoinChainService method bodies (run on the captured runtime
266    //      via the outer struct's `run_on_runtime` helper) ---------------
267
268    async fn do_get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
269        let address = address
270            .parse::<Address<NetworkUnchecked>>()?
271            .require_network(self.network.into())?;
272
273        let utxos = self
274            .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
275            .await?;
276
277        Ok(utxos)
278    }
279
280    async fn do_get_transaction_status(
281        &self,
282        txid: String,
283    ) -> Result<super::TxStatus, ChainServiceError> {
284        let tx_info = self
285            .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
286            .await?;
287        Ok(tx_info.status)
288    }
289
290    async fn do_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
291        let tx = self
292            .get_response_text(format!("/tx/{txid}/hex").as_str())
293            .await?;
294        Ok(tx)
295    }
296
297    async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
298        let url = format!("{}{}", self.base_url, "/tx");
299        self.post(&url, Some(tx)).await?;
300        Ok(())
301    }
302
303    async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
304        match self.api_type {
305            ChainApiType::Esplora => self.recommended_fees_esplora().await,
306            ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
307        }
308    }
309}
310
311#[macros::async_trait]
312impl BitcoinChainService for RestClientChainService {
313    async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
314        self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
315            .await
316    }
317
318    async fn get_transaction_status(
319        &self,
320        txid: String,
321    ) -> Result<super::TxStatus, ChainServiceError> {
322        self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
323            .await
324    }
325
326    async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
327        self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
328            .await
329    }
330
331    async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
332        self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
333            .await
334    }
335
336    async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
337        self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
338            .await
339    }
340}
341
342fn is_status_retryable(status: u16) -> bool {
343    RETRYABLE_ERROR_CODES.contains(&status)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::Network;
350
351    use macros::async_test_all;
352
353    #[cfg(feature = "browser-tests")]
354    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
355
356    #[cfg(test)]
357    use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
358
359    #[async_test_all]
360    async fn test_get_address_utxos() {
361        // Mock JSON response from the actual API call
362        let mock_response = r#"[
363            {
364                "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
365                "vout": 3,
366                "status": {
367                    "confirmed": true,
368                    "block_height": 725850,
369                    "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
370                    "block_time": 1646382740
371                },
372                "value": 24201
373            },
374            {
375                "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
376                "vout": 0,
377                "status": {
378                    "confirmed": true,
379                    "block_height": 840719,
380                    "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
381                    "block_time": 1713994081
382                },
383                "value": 30236
384            },
385            {
386                "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
387                "vout": 74,
388                "status": {
389                    "confirmed": true,
390                    "block_height": 726892,
391                    "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
392                    "block_time": 1647033214
393                },
394                "value": 5155
395            },
396            {
397                "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
398                "vout": 0,
399                "status": {
400                    "confirmed": true,
401                    "block_height": 857808,
402                    "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
403                    "block_time": 1724272387
404                },
405                "value": 6127
406            },
407            {
408                "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
409                "vout": 126,
410                "status": {
411                    "confirmed": true,
412                    "block_height": 748177,
413                    "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
414                    "block_time": 1659763398
415                },
416                "value": 22190
417            }
418        ]"#;
419
420        let mock = MockRestClient::new();
421        mock.add_response(MockResponse::new(200, mock_response.to_string()));
422
423        // Create the service with the mock server URL
424        let service = RestClientChainService::new(
425            "http://localhost:8080".to_string(),
426            Network::Mainnet,
427            3,
428            Arc::new(mock),
429            None,
430            ChainApiType::Esplora,
431        );
432
433        // Call the method under test
434        let mut result = service
435            .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
436            .await
437            .unwrap();
438
439        // Sort results by value for consistent testing
440        result.sort_by_key(|a| a.value);
441
442        // Verify we got the expected number of UTXOs
443        assert_eq!(result.len(), 5);
444
445        // Verify the UTXOs are correctly parsed and sorted by value
446        assert_eq!(result[0].value, 5155); // Smallest value
447        assert_eq!(
448            result[0].txid,
449            "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
450        );
451        assert_eq!(result[0].vout, 74);
452        assert!(result[0].status.confirmed);
453        assert_eq!(result[0].status.block_height, Some(726_892));
454
455        assert_eq!(result[1].value, 6127);
456        assert_eq!(
457            result[1].txid,
458            "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
459        );
460
461        assert_eq!(result[2].value, 22190);
462        assert_eq!(result[3].value, 24201);
463        assert_eq!(result[4].value, 30236); // Largest value
464
465        // Verify all UTXOs are confirmed
466        for utxo in &result {
467            assert!(utxo.status.confirmed);
468            assert!(utxo.status.block_height.is_some());
469            assert!(utxo.status.block_time.is_some());
470        }
471    }
472}