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 } = client.get(url.to_string(), Some(headers)).await?;
202            match status {
203                status if attempts < self.max_retries && is_status_retryable(status) => {
204                    tokio::time::sleep(delay).await;
205                    attempts = attempts.saturating_add(1);
206                    delay = delay.saturating_mul(2);
207                }
208                _ => {
209                    if !(200..300).contains(&status) {
210                        return Err(HttpError::Status { status, body }.into());
211                    }
212                    return Ok((body, status));
213                }
214            }
215        }
216    }
217
218    async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
219        let mut headers: HashMap<String, String> = HashMap::new();
220        add_content_type_header(&mut headers, ContentType::TextPlain);
221        if let Some(basic_auth) = &self.basic_auth {
222            add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
223        }
224        info!(
225            "Posting to {} with body {} and headers {:?}",
226            url,
227            body.clone().unwrap_or_default(),
228            headers
229        );
230        let HttpResponse { body, status } = self
231            .client
232            .post(url.to_string(), Some(headers), body)
233            .await?;
234        if !(200..300).contains(&status) {
235            return Err(HttpError::Status { status, body }.into());
236        }
237
238        Ok(body)
239    }
240
241    async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
242        let fee_map = self
243            .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
244            .await?;
245        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
246        let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
247
248        Ok(RecommendedFees {
249            fastest_fee: get_fees(&1),
250            half_hour_fee: get_fees(&3),
251            hour_fee: get_fees(&6),
252            economy_fee: get_fees(&25),
253            minimum_fee: get_fees(&1008),
254        })
255    }
256
257    async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
258        let response = self
259            .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
260            .await?;
261        Ok(response.into())
262    }
263
264    // ---- BitcoinChainService method bodies (run on the captured runtime
265    //      via the outer struct's `run_on_runtime` helper) ---------------
266
267    async fn do_get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
268        let address = address
269            .parse::<Address<NetworkUnchecked>>()?
270            .require_network(self.network.into())?;
271
272        let utxos = self
273            .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
274            .await?;
275
276        Ok(utxos)
277    }
278
279    async fn do_get_transaction_status(
280        &self,
281        txid: String,
282    ) -> Result<super::TxStatus, ChainServiceError> {
283        let tx_info = self
284            .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
285            .await?;
286        Ok(tx_info.status)
287    }
288
289    async fn do_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
290        let tx = self
291            .get_response_text(format!("/tx/{txid}/hex").as_str())
292            .await?;
293        Ok(tx)
294    }
295
296    async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
297        let url = format!("{}{}", self.base_url, "/tx");
298        self.post(&url, Some(tx)).await?;
299        Ok(())
300    }
301
302    async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
303        match self.api_type {
304            ChainApiType::Esplora => self.recommended_fees_esplora().await,
305            ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
306        }
307    }
308}
309
310#[macros::async_trait]
311impl BitcoinChainService for RestClientChainService {
312    async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
313        self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
314            .await
315    }
316
317    async fn get_transaction_status(
318        &self,
319        txid: String,
320    ) -> Result<super::TxStatus, ChainServiceError> {
321        self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
322            .await
323    }
324
325    async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
326        self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
327            .await
328    }
329
330    async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
331        self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
332            .await
333    }
334
335    async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
336        self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
337            .await
338    }
339}
340
341fn is_status_retryable(status: u16) -> bool {
342    RETRYABLE_ERROR_CODES.contains(&status)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::Network;
349
350    use macros::async_test_all;
351
352    #[cfg(feature = "browser-tests")]
353    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
354
355    #[cfg(test)]
356    use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
357
358    #[async_test_all]
359    async fn test_get_address_utxos() {
360        // Mock JSON response from the actual API call
361        let mock_response = r#"[
362            {
363                "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
364                "vout": 3,
365                "status": {
366                    "confirmed": true,
367                    "block_height": 725850,
368                    "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
369                    "block_time": 1646382740
370                },
371                "value": 24201
372            },
373            {
374                "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
375                "vout": 0,
376                "status": {
377                    "confirmed": true,
378                    "block_height": 840719,
379                    "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
380                    "block_time": 1713994081
381                },
382                "value": 30236
383            },
384            {
385                "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
386                "vout": 74,
387                "status": {
388                    "confirmed": true,
389                    "block_height": 726892,
390                    "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
391                    "block_time": 1647033214
392                },
393                "value": 5155
394            },
395            {
396                "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
397                "vout": 0,
398                "status": {
399                    "confirmed": true,
400                    "block_height": 857808,
401                    "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
402                    "block_time": 1724272387
403                },
404                "value": 6127
405            },
406            {
407                "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
408                "vout": 126,
409                "status": {
410                    "confirmed": true,
411                    "block_height": 748177,
412                    "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
413                    "block_time": 1659763398
414                },
415                "value": 22190
416            }
417        ]"#;
418
419        let mock = MockRestClient::new();
420        mock.add_response(MockResponse::new(200, mock_response.to_string()));
421
422        // Create the service with the mock server URL
423        let service = RestClientChainService::new(
424            "http://localhost:8080".to_string(),
425            Network::Mainnet,
426            3,
427            Arc::new(mock),
428            None,
429            ChainApiType::Esplora,
430        );
431
432        // Call the method under test
433        let mut result = service
434            .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
435            .await
436            .unwrap();
437
438        // Sort results by value for consistent testing
439        result.sort_by_key(|a| a.value);
440
441        // Verify we got the expected number of UTXOs
442        assert_eq!(result.len(), 5);
443
444        // Verify the UTXOs are correctly parsed and sorted by value
445        assert_eq!(result[0].value, 5155); // Smallest value
446        assert_eq!(
447            result[0].txid,
448            "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
449        );
450        assert_eq!(result[0].vout, 74);
451        assert!(result[0].status.confirmed);
452        assert_eq!(result[0].status.block_height, Some(726_892));
453
454        assert_eq!(result[1].value, 6127);
455        assert_eq!(
456            result[1].txid,
457            "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
458        );
459
460        assert_eq!(result[2].value, 22190);
461        assert_eq!(result[3].value, 24201);
462        assert_eq!(result[4].value, 30236); // Largest value
463
464        // Verify all UTXOs are confirmed
465        for utxo in &result {
466            assert!(utxo.status.confirmed);
467            assert!(utxo.status.block_height.is_some());
468            assert!(utxo.status.block_time.is_some());
469        }
470    }
471}