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::{debug, info};
12
13use crate::chain::RecommendedFees;
14use crate::{
15    Network,
16    chain::{ChainServiceError, Outspend, 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
36/// Minimal esplora `/address/:address/txs` entry: the txid, its confirmation
37/// status, and the outputs (scanned to find the ones paying the queried address).
38#[derive(Deserialize)]
39struct AddressTx {
40    txid: String,
41    status: super::TxStatus,
42    vout: Vec<AddressTxVout>,
43}
44
45#[derive(Deserialize)]
46struct AddressTxVout {
47    #[serde(default)]
48    scriptpubkey_address: Option<String>,
49    value: u64,
50}
51
52pub struct BasicAuth {
53    username: String,
54    password: String,
55}
56
57impl BasicAuth {
58    pub fn new(username: String, password: String) -> Self {
59        Self { username, password }
60    }
61}
62
63struct RestClientChainServiceInner {
64    base_url: String,
65    network: Network,
66    client: Arc<dyn HttpClient>,
67    max_retries: usize,
68    basic_auth: Option<BasicAuth>,
69    api_type: ChainApiType,
70}
71
72/// REST-backed [`BitcoinChainService`].
73///
74/// The trait is exported through `UniFFI` with `with_foreign`, which makes
75/// `UniFFI` re-wrap every `Arc<dyn BitcoinChainService>` that round-trips
76/// across the FFI boundary in a foreign-callback proxy — even when both
77/// sides are Rust in the same process. That proxy routes calls back into
78/// Rust via `UniFFI`'s `RustFuture`, which is polled outside the surrounding
79/// tokio runtime context, so `reqwest`'s `tokio::time::sleep` panics with
80/// "no reactor running".
81///
82/// To stay correct under round-tripping (e.g. shared-chain-service in a
83/// server-side harness that builds the service in Rust, hands it to a
84/// foreign-language integration, and passes it back into multiple SDK
85/// instances), we capture a [`tokio::runtime::Handle`] at construction
86/// time and dispatch each trait-method body onto it via
87/// [`tokio::runtime::Handle::spawn`]. The outer future we return to
88/// `UniFFI` is just a `JoinHandle` await — a channel-wakeup poll that
89/// needs no tokio context.
90pub struct RestClientChainService {
91    inner: Arc<RestClientChainServiceInner>,
92    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
93    runtime_handle: tokio::runtime::Handle,
94}
95
96#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
97#[derive(Clone, Copy, Debug)]
98pub enum ChainApiType {
99    Esplora,
100    MempoolSpace,
101}
102
103#[derive(Deserialize)]
104#[serde(rename_all = "camelCase")]
105struct MempoolSpaceRecommendedFeesResponse {
106    fastest_fee: f64,
107    half_hour_fee: f64,
108    hour_fee: f64,
109    economy_fee: f64,
110    minimum_fee: f64,
111}
112
113impl From<MempoolSpaceRecommendedFeesResponse> for RecommendedFees {
114    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
115    fn from(response: MempoolSpaceRecommendedFeesResponse) -> Self {
116        Self {
117            fastest_fee: response.fastest_fee.ceil() as u64,
118            half_hour_fee: response.half_hour_fee.ceil() as u64,
119            hour_fee: response.hour_fee.ceil() as u64,
120            economy_fee: response.economy_fee.ceil() as u64,
121            minimum_fee: response.minimum_fee.ceil() as u64,
122        }
123    }
124}
125
126impl RestClientChainService {
127    pub fn new(
128        base_url: String,
129        network: Network,
130        max_retries: usize,
131        http_client: Arc<dyn HttpClient>,
132        basic_auth: Option<BasicAuth>,
133        api_type: ChainApiType,
134    ) -> Self {
135        Self {
136            inner: Arc::new(RestClientChainServiceInner {
137                base_url,
138                network,
139                client: http_client,
140                max_retries,
141                basic_auth,
142                api_type,
143            }),
144            // Captured here so each trait-method body can re-enter the
145            // surrounding runtime even when invoked from a `UniFFI`
146            // foreign-callback proxy that polls outside any tokio context.
147            // Callers reach this constructor from within an async path
148            // (`new_rest_chain_service`, `SdkBuilder::build`, etc.), so
149            // `Handle::current()` is guaranteed to find a runtime.
150            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
151            runtime_handle: tokio::runtime::Handle::current(),
152        }
153    }
154
155    /// Runs `work` on the captured tokio runtime (non-WASM) or inline
156    /// (WASM, where there's no separate runtime to dispatch onto).
157    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
158    async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
159    where
160        F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut + Send + 'static,
161        Fut: std::future::Future<Output = Result<T, ChainServiceError>> + Send,
162        T: Send + 'static,
163    {
164        let inner = self.inner.clone();
165        self.runtime_handle
166            .spawn(async move { work(inner).await })
167            .await
168            .map_err(|e| ChainServiceError::Generic(format!("join error: {e}")))?
169    }
170
171    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
172    async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
173    where
174        F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut,
175        Fut: std::future::Future<Output = Result<T, ChainServiceError>>,
176    {
177        work(self.inner.clone()).await
178    }
179}
180
181impl RestClientChainServiceInner {
182    async fn get_response_json<T: serde::de::DeserializeOwned>(
183        &self,
184        path: &str,
185    ) -> Result<T, ChainServiceError> {
186        let url = format!("{}{}", self.base_url, path);
187        debug!("Fetching response json from {}", url);
188        let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
189
190        let response: T = serde_json::from_str(&response)
191            .map_err(|e| ChainServiceError::Generic(e.to_string()))?;
192
193        Ok(response)
194    }
195
196    async fn get_response_text(&self, path: &str) -> Result<String, ChainServiceError> {
197        let url = format!("{}{}", self.base_url, path);
198        debug!("Fetching response text from {}", url);
199        let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
200        Ok(response)
201    }
202
203    async fn get_with_retry(
204        &self,
205        url: &str,
206        client: &dyn HttpClient,
207    ) -> Result<(String, u16), ChainServiceError> {
208        let mut delay = BASE_BACKOFF_MILLIS;
209        let mut attempts = 0;
210
211        loop {
212            let mut headers = HashMap::new();
213            if let Some(basic_auth) = &self.basic_auth {
214                add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
215            }
216
217            let HttpResponse { body, status, .. } =
218                client.get(url.to_string(), Some(headers)).await?;
219            match status {
220                status if attempts < self.max_retries && is_status_retryable(status) => {
221                    tokio::time::sleep(delay).await;
222                    attempts = attempts.saturating_add(1);
223                    delay = delay.saturating_mul(2);
224                }
225                _ => {
226                    if !(200..300).contains(&status) {
227                        return Err(HttpError::Status { status, body }.into());
228                    }
229                    return Ok((body, status));
230                }
231            }
232        }
233    }
234
235    async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
236        let mut headers: HashMap<String, String> = HashMap::new();
237        add_content_type_header(&mut headers, ContentType::TextPlain);
238        if let Some(basic_auth) = &self.basic_auth {
239            add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
240        }
241        info!("Posting to {}", url);
242        debug!(
243            "Posting to {} with body {}",
244            url,
245            body.clone().unwrap_or_default()
246        );
247        let HttpResponse { body, status, .. } = self
248            .client
249            .post(url.to_string(), Some(headers), body)
250            .await?;
251        if !(200..300).contains(&status) {
252            return Err(HttpError::Status { status, body }.into());
253        }
254
255        Ok(body)
256    }
257
258    async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
259        let fee_map = self
260            .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
261            .await?;
262        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
263        let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
264
265        Ok(RecommendedFees {
266            fastest_fee: get_fees(&1),
267            half_hour_fee: get_fees(&3),
268            hour_fee: get_fees(&6),
269            economy_fee: get_fees(&25),
270            minimum_fee: get_fees(&1008),
271        })
272    }
273
274    async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
275        let response = self
276            .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
277            .await?;
278        Ok(response.into())
279    }
280
281    // ---- BitcoinChainService method bodies (run on the captured runtime
282    //      via the outer struct's `run_on_runtime` helper) ---------------
283
284    async fn do_get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
285        let address = address
286            .parse::<Address<NetworkUnchecked>>()?
287            .require_network(self.network.into())?;
288
289        let utxos = self
290            .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
291            .await?;
292
293        Ok(utxos)
294    }
295
296    async fn do_get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
297        let address = address
298            .parse::<Address<NetworkUnchecked>>()?
299            .require_network(self.network.into())?;
300        let address_str = address.to_string();
301
302        // A refund address only ever holds its one refund plus the sweep spending
303        // it, so a single (un-paginated) page of history covers it.
304        let txs = self
305            .get_response_json::<Vec<AddressTx>>(format!("/address/{address}/txs").as_str())
306            .await?;
307        let mut txos = Vec::new();
308        for tx in txs {
309            for (vout, out) in tx.vout.iter().enumerate() {
310                if out.scriptpubkey_address.as_deref() != Some(address_str.as_str()) {
311                    continue;
312                }
313                txos.push(Utxo {
314                    txid: tx.txid.clone(),
315                    vout: u32::try_from(vout)
316                        .map_err(|_| ChainServiceError::Generic("vout overflow".to_string()))?,
317                    value: out.value,
318                    status: tx.status.clone(),
319                });
320            }
321        }
322        Ok(txos)
323    }
324
325    async fn do_get_transaction_status(
326        &self,
327        txid: String,
328    ) -> Result<super::TxStatus, ChainServiceError> {
329        let tx_info = self
330            .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
331            .await?;
332        Ok(tx_info.status)
333    }
334
335    async fn do_tip_height(&self) -> Result<u32, ChainServiceError> {
336        // Plain-text height, served by both esplora and mempool.space.
337        let height = self.get_response_text("/blocks/tip/height").await?;
338        height
339            .trim()
340            .parse()
341            .map_err(|_| ChainServiceError::Generic(format!("invalid tip height: {height}")))
342    }
343
344    async fn do_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
345        let tx = self
346            .get_response_text(format!("/tx/{txid}/hex").as_str())
347            .await?;
348        Ok(tx)
349    }
350
351    async fn do_get_outspend(
352        &self,
353        txid: String,
354        vout: u32,
355    ) -> Result<Outspend, ChainServiceError> {
356        let outspend = self
357            .get_response_json::<Outspend>(format!("/tx/{txid}/outspend/{vout}").as_str())
358            .await?;
359        Ok(outspend)
360    }
361
362    async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
363        let url = format!("{}{}", self.base_url, "/tx");
364        self.post(&url, Some(tx)).await?;
365        Ok(())
366    }
367
368    async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
369        match self.api_type {
370            ChainApiType::Esplora => self.recommended_fees_esplora().await,
371            ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
372        }
373    }
374}
375
376#[macros::async_trait]
377impl BitcoinChainService for RestClientChainService {
378    async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
379        self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
380            .await
381    }
382
383    async fn get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
384        self.run_on_runtime(|inner| async move { inner.do_get_address_txos(address).await })
385            .await
386    }
387
388    async fn get_transaction_status(
389        &self,
390        txid: String,
391    ) -> Result<super::TxStatus, ChainServiceError> {
392        self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
393            .await
394    }
395
396    async fn tip_height(&self) -> Result<u32, ChainServiceError> {
397        self.run_on_runtime(|inner| async move { inner.do_tip_height().await })
398            .await
399    }
400
401    async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
402        self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
403            .await
404    }
405
406    async fn get_outspend(&self, txid: String, vout: u32) -> Result<Outspend, ChainServiceError> {
407        self.run_on_runtime(move |inner| async move { inner.do_get_outspend(txid, vout).await })
408            .await
409    }
410
411    async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
412        self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
413            .await
414    }
415
416    async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
417        self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
418            .await
419    }
420}
421
422fn is_status_retryable(status: u16) -> bool {
423    RETRYABLE_ERROR_CODES.contains(&status)
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::Network;
430
431    use macros::async_test_all;
432
433    #[cfg(feature = "browser-tests")]
434    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
435
436    #[cfg(test)]
437    use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
438
439    #[async_test_all]
440    async fn test_get_address_utxos() {
441        // Mock JSON response from the actual API call
442        let mock_response = r#"[
443            {
444                "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
445                "vout": 3,
446                "status": {
447                    "confirmed": true,
448                    "block_height": 725850,
449                    "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
450                    "block_time": 1646382740
451                },
452                "value": 24201
453            },
454            {
455                "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
456                "vout": 0,
457                "status": {
458                    "confirmed": true,
459                    "block_height": 840719,
460                    "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
461                    "block_time": 1713994081
462                },
463                "value": 30236
464            },
465            {
466                "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
467                "vout": 74,
468                "status": {
469                    "confirmed": true,
470                    "block_height": 726892,
471                    "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
472                    "block_time": 1647033214
473                },
474                "value": 5155
475            },
476            {
477                "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
478                "vout": 0,
479                "status": {
480                    "confirmed": true,
481                    "block_height": 857808,
482                    "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
483                    "block_time": 1724272387
484                },
485                "value": 6127
486            },
487            {
488                "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
489                "vout": 126,
490                "status": {
491                    "confirmed": true,
492                    "block_height": 748177,
493                    "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
494                    "block_time": 1659763398
495                },
496                "value": 22190
497            }
498        ]"#;
499
500        let mock = MockRestClient::new();
501        mock.add_response(MockResponse::new(200, mock_response.to_string()));
502
503        // Create the service with the mock server URL
504        let service = RestClientChainService::new(
505            "http://localhost:8080".to_string(),
506            Network::Mainnet,
507            3,
508            Arc::new(mock),
509            None,
510            ChainApiType::Esplora,
511        );
512
513        // Call the method under test
514        let mut result = service
515            .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
516            .await
517            .unwrap();
518
519        // Sort results by value for consistent testing
520        result.sort_by_key(|a| a.value);
521
522        // Verify we got the expected number of UTXOs
523        assert_eq!(result.len(), 5);
524
525        // Verify the UTXOs are correctly parsed and sorted by value
526        assert_eq!(result[0].value, 5155); // Smallest value
527        assert_eq!(
528            result[0].txid,
529            "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
530        );
531        assert_eq!(result[0].vout, 74);
532        assert!(result[0].status.confirmed);
533        assert_eq!(result[0].status.block_height, Some(726_892));
534
535        assert_eq!(result[1].value, 6127);
536        assert_eq!(
537            result[1].txid,
538            "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
539        );
540
541        assert_eq!(result[2].value, 22190);
542        assert_eq!(result[3].value, 24201);
543        assert_eq!(result[4].value, 30236); // Largest value
544
545        // Verify all UTXOs are confirmed
546        for utxo in &result {
547            assert!(utxo.status.confirmed);
548            assert!(utxo.status.block_height.is_some());
549            assert!(utxo.status.block_time.is_some());
550        }
551    }
552
553    #[async_test_all]
554    async fn test_get_address_txos_returns_spent_outputs() {
555        let address = "1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv";
556        // The refund tx pays the address (vout 0); a later sweep spends it and pays
557        // elsewhere. get_address_txos must still surface the refund output.
558        let mock_response = format!(
559            r#"[
560                {{
561                    "txid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
562                    "status": {{ "confirmed": false }},
563                    "vout": [
564                        {{ "scriptpubkey_address": "1DestinationaaaaaaaaaaaaaaaaaaaaZ", "value": 49000 }}
565                    ]
566                }},
567                {{
568                    "txid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
569                    "status": {{ "confirmed": true, "block_height": 800000, "block_time": 1700000000 }},
570                    "vout": [
571                        {{ "scriptpubkey_address": "{address}", "value": 50000 }},
572                        {{ "scriptpubkey_address": "1OtheraaaaaaaaaaaaaaaaaaaaaaaaaaZ", "value": 10000 }}
573                    ]
574                }}
575            ]"#
576        );
577
578        let mock = MockRestClient::new();
579        mock.add_response(MockResponse::new(200, mock_response));
580        let service = RestClientChainService::new(
581            "http://localhost:8080".to_string(),
582            Network::Mainnet,
583            3,
584            Arc::new(mock),
585            None,
586            ChainApiType::Esplora,
587        );
588
589        let result = service.get_address_txos(address.to_string()).await.unwrap();
590
591        assert_eq!(
592            result.len(),
593            1,
594            "only the output paying the address is kept"
595        );
596        assert_eq!(
597            result[0].txid,
598            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
599        );
600        assert_eq!(result[0].vout, 0);
601        assert_eq!(result[0].value, 50000);
602        assert!(result[0].status.confirmed);
603    }
604}