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, 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        info!("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        info!("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!(
242            "Posting to {} with body {} and headers {:?}",
243            url,
244            body.clone().unwrap_or_default(),
245            headers
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_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
336        let tx = self
337            .get_response_text(format!("/tx/{txid}/hex").as_str())
338            .await?;
339        Ok(tx)
340    }
341
342    async fn do_get_outspend(
343        &self,
344        txid: String,
345        vout: u32,
346    ) -> Result<Outspend, ChainServiceError> {
347        let outspend = self
348            .get_response_json::<Outspend>(format!("/tx/{txid}/outspend/{vout}").as_str())
349            .await?;
350        Ok(outspend)
351    }
352
353    async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
354        let url = format!("{}{}", self.base_url, "/tx");
355        self.post(&url, Some(tx)).await?;
356        Ok(())
357    }
358
359    async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
360        match self.api_type {
361            ChainApiType::Esplora => self.recommended_fees_esplora().await,
362            ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
363        }
364    }
365}
366
367#[macros::async_trait]
368impl BitcoinChainService for RestClientChainService {
369    async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
370        self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
371            .await
372    }
373
374    async fn get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
375        self.run_on_runtime(|inner| async move { inner.do_get_address_txos(address).await })
376            .await
377    }
378
379    async fn get_transaction_status(
380        &self,
381        txid: String,
382    ) -> Result<super::TxStatus, ChainServiceError> {
383        self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
384            .await
385    }
386
387    async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
388        self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
389            .await
390    }
391
392    async fn get_outspend(&self, txid: String, vout: u32) -> Result<Outspend, ChainServiceError> {
393        self.run_on_runtime(move |inner| async move { inner.do_get_outspend(txid, vout).await })
394            .await
395    }
396
397    async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
398        self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
399            .await
400    }
401
402    async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
403        self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
404            .await
405    }
406}
407
408fn is_status_retryable(status: u16) -> bool {
409    RETRYABLE_ERROR_CODES.contains(&status)
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::Network;
416
417    use macros::async_test_all;
418
419    #[cfg(feature = "browser-tests")]
420    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
421
422    #[cfg(test)]
423    use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
424
425    #[async_test_all]
426    async fn test_get_address_utxos() {
427        // Mock JSON response from the actual API call
428        let mock_response = r#"[
429            {
430                "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
431                "vout": 3,
432                "status": {
433                    "confirmed": true,
434                    "block_height": 725850,
435                    "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
436                    "block_time": 1646382740
437                },
438                "value": 24201
439            },
440            {
441                "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
442                "vout": 0,
443                "status": {
444                    "confirmed": true,
445                    "block_height": 840719,
446                    "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
447                    "block_time": 1713994081
448                },
449                "value": 30236
450            },
451            {
452                "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
453                "vout": 74,
454                "status": {
455                    "confirmed": true,
456                    "block_height": 726892,
457                    "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
458                    "block_time": 1647033214
459                },
460                "value": 5155
461            },
462            {
463                "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
464                "vout": 0,
465                "status": {
466                    "confirmed": true,
467                    "block_height": 857808,
468                    "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
469                    "block_time": 1724272387
470                },
471                "value": 6127
472            },
473            {
474                "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
475                "vout": 126,
476                "status": {
477                    "confirmed": true,
478                    "block_height": 748177,
479                    "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
480                    "block_time": 1659763398
481                },
482                "value": 22190
483            }
484        ]"#;
485
486        let mock = MockRestClient::new();
487        mock.add_response(MockResponse::new(200, mock_response.to_string()));
488
489        // Create the service with the mock server URL
490        let service = RestClientChainService::new(
491            "http://localhost:8080".to_string(),
492            Network::Mainnet,
493            3,
494            Arc::new(mock),
495            None,
496            ChainApiType::Esplora,
497        );
498
499        // Call the method under test
500        let mut result = service
501            .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
502            .await
503            .unwrap();
504
505        // Sort results by value for consistent testing
506        result.sort_by_key(|a| a.value);
507
508        // Verify we got the expected number of UTXOs
509        assert_eq!(result.len(), 5);
510
511        // Verify the UTXOs are correctly parsed and sorted by value
512        assert_eq!(result[0].value, 5155); // Smallest value
513        assert_eq!(
514            result[0].txid,
515            "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
516        );
517        assert_eq!(result[0].vout, 74);
518        assert!(result[0].status.confirmed);
519        assert_eq!(result[0].status.block_height, Some(726_892));
520
521        assert_eq!(result[1].value, 6127);
522        assert_eq!(
523            result[1].txid,
524            "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
525        );
526
527        assert_eq!(result[2].value, 22190);
528        assert_eq!(result[3].value, 24201);
529        assert_eq!(result[4].value, 30236); // Largest value
530
531        // Verify all UTXOs are confirmed
532        for utxo in &result {
533            assert!(utxo.status.confirmed);
534            assert!(utxo.status.block_height.is_some());
535            assert!(utxo.status.block_time.is_some());
536        }
537    }
538
539    #[async_test_all]
540    async fn test_get_address_txos_returns_spent_outputs() {
541        let address = "1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv";
542        // The refund tx pays the address (vout 0); a later sweep spends it and pays
543        // elsewhere. get_address_txos must still surface the refund output.
544        let mock_response = format!(
545            r#"[
546                {{
547                    "txid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
548                    "status": {{ "confirmed": false }},
549                    "vout": [
550                        {{ "scriptpubkey_address": "1DestinationaaaaaaaaaaaaaaaaaaaaZ", "value": 49000 }}
551                    ]
552                }},
553                {{
554                    "txid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
555                    "status": {{ "confirmed": true, "block_height": 800000, "block_time": 1700000000 }},
556                    "vout": [
557                        {{ "scriptpubkey_address": "{address}", "value": 50000 }},
558                        {{ "scriptpubkey_address": "1OtheraaaaaaaaaaaaaaaaaaaaaaaaaaZ", "value": 10000 }}
559                    ]
560                }}
561            ]"#
562        );
563
564        let mock = MockRestClient::new();
565        mock.add_response(MockResponse::new(200, mock_response));
566        let service = RestClientChainService::new(
567            "http://localhost:8080".to_string(),
568            Network::Mainnet,
569            3,
570            Arc::new(mock),
571            None,
572            ChainApiType::Esplora,
573        );
574
575        let result = service.get_address_txos(address.to_string()).await.unwrap();
576
577        assert_eq!(
578            result.len(),
579            1,
580            "only the output paying the address is kept"
581        );
582        assert_eq!(
583            result[0].txid,
584            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
585        );
586        assert_eq!(result[0].vout, 0);
587        assert_eq!(result[0].value, 50000);
588        assert!(result[0].status.confirmed);
589    }
590}