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, REQUEST_TIMEOUT, 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/// Ceiling on a whole retrying call, so retries cost attempts rather than
31/// multiplying how long a caller can be blocked for.
32const TOTAL_TIMEOUT: Duration = Duration::from_secs(REQUEST_TIMEOUT);
33
34#[derive(Serialize, Deserialize, Clone)]
35struct TxInfo {
36    txid: String,
37    status: super::TxStatus,
38}
39
40/// Minimal esplora `/address/:address/txs` entry: the txid, its confirmation
41/// status, and the outputs (scanned to find the ones paying the queried address).
42#[derive(Deserialize)]
43struct AddressTx {
44    txid: String,
45    status: super::TxStatus,
46    vout: Vec<AddressTxVout>,
47}
48
49#[derive(Deserialize)]
50struct AddressTxVout {
51    #[serde(default)]
52    scriptpubkey_address: Option<String>,
53    value: u64,
54}
55
56pub struct BasicAuth {
57    username: String,
58    password: String,
59}
60
61impl BasicAuth {
62    pub fn new(username: String, password: String) -> Self {
63        Self { username, password }
64    }
65}
66
67struct RestClientChainServiceInner {
68    base_url: String,
69    network: Network,
70    client: Arc<dyn HttpClient>,
71    max_retries: usize,
72    basic_auth: Option<BasicAuth>,
73    api_type: ChainApiType,
74    total_timeout: Duration,
75}
76
77/// REST-backed [`BitcoinChainService`].
78///
79/// The trait is exported through `UniFFI` with `with_foreign`, which makes
80/// `UniFFI` re-wrap every `Arc<dyn BitcoinChainService>` that round-trips
81/// across the FFI boundary in a foreign-callback proxy — even when both
82/// sides are Rust in the same process. That proxy routes calls back into
83/// Rust via `UniFFI`'s `RustFuture`, which is polled outside the surrounding
84/// tokio runtime context, so `reqwest`'s `tokio::time::sleep` panics with
85/// "no reactor running".
86///
87/// To stay correct under round-tripping (e.g. shared-chain-service in a
88/// server-side harness that builds the service in Rust, hands it to a
89/// foreign-language integration, and passes it back into multiple SDK
90/// instances), we capture a [`tokio::runtime::Handle`] at construction
91/// time and dispatch each trait-method body onto it via
92/// [`tokio::runtime::Handle::spawn`]. The outer future we return to
93/// `UniFFI` is just a `JoinHandle` await — a channel-wakeup poll that
94/// needs no tokio context.
95pub struct RestClientChainService {
96    inner: Arc<RestClientChainServiceInner>,
97    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
98    runtime_handle: tokio::runtime::Handle,
99}
100
101#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
102#[derive(Clone, Copy, Debug)]
103pub enum ChainApiType {
104    Esplora,
105    MempoolSpace,
106}
107
108#[derive(Deserialize)]
109#[serde(rename_all = "camelCase")]
110struct MempoolSpaceRecommendedFeesResponse {
111    fastest_fee: f64,
112    half_hour_fee: f64,
113    hour_fee: f64,
114    economy_fee: f64,
115    minimum_fee: f64,
116}
117
118impl From<MempoolSpaceRecommendedFeesResponse> for RecommendedFees {
119    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
120    fn from(response: MempoolSpaceRecommendedFeesResponse) -> Self {
121        Self {
122            fastest_fee: response.fastest_fee.ceil() as u64,
123            half_hour_fee: response.half_hour_fee.ceil() as u64,
124            hour_fee: response.hour_fee.ceil() as u64,
125            economy_fee: response.economy_fee.ceil() as u64,
126            minimum_fee: response.minimum_fee.ceil() as u64,
127        }
128    }
129}
130
131impl RestClientChainService {
132    pub fn new(
133        base_url: String,
134        network: Network,
135        max_retries: usize,
136        http_client: Arc<dyn HttpClient>,
137        basic_auth: Option<BasicAuth>,
138        api_type: ChainApiType,
139    ) -> Self {
140        Self {
141            inner: Arc::new(RestClientChainServiceInner {
142                base_url,
143                network,
144                client: http_client,
145                max_retries,
146                basic_auth,
147                api_type,
148                total_timeout: TOTAL_TIMEOUT,
149            }),
150            // Captured here so each trait-method body can re-enter the
151            // surrounding runtime even when invoked from a `UniFFI`
152            // foreign-callback proxy that polls outside any tokio context.
153            // Callers reach this constructor from within an async path
154            // (`new_rest_chain_service`, `SdkBuilder::build`, etc.), so
155            // `Handle::current()` is guaranteed to find a runtime.
156            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
157            runtime_handle: tokio::runtime::Handle::current(),
158        }
159    }
160
161    /// Caps each whole retrying call at `total_timeout` instead of the default,
162    /// so a caller with a fallback backend gives up on this one sooner.
163    #[must_use]
164    pub(crate) fn with_total_timeout(mut self, total_timeout: Duration) -> Self {
165        if let Some(inner) = Arc::get_mut(&mut self.inner) {
166            inner.total_timeout = total_timeout;
167        }
168        self
169    }
170
171    /// Runs `work` on the captured tokio runtime (non-WASM) or inline
172    /// (WASM, where there's no separate runtime to dispatch onto).
173    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
174    async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
175    where
176        F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut + Send + 'static,
177        Fut: std::future::Future<Output = Result<T, ChainServiceError>> + Send,
178        T: Send + 'static,
179    {
180        use tracing::Instrument;
181
182        let inner = self.inner.clone();
183        self.runtime_handle
184            .spawn(async move { work(inner).await }.in_current_span())
185            .await
186            .map_err(|e| ChainServiceError::Generic(format!("join error: {e}")))?
187    }
188
189    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
190    async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
191    where
192        F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut,
193        Fut: std::future::Future<Output = Result<T, ChainServiceError>>,
194    {
195        work(self.inner.clone()).await
196    }
197}
198
199impl RestClientChainServiceInner {
200    async fn get_response_json<T: serde::de::DeserializeOwned>(
201        &self,
202        path: &str,
203    ) -> Result<T, ChainServiceError> {
204        let url = format!("{}{}", self.base_url, path);
205        debug!("Fetching response json from {}", url);
206        let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
207
208        let response: T = serde_json::from_str(&response)
209            .map_err(|e| ChainServiceError::Generic(e.to_string()))?;
210
211        Ok(response)
212    }
213
214    async fn get_response_text(&self, path: &str) -> Result<String, ChainServiceError> {
215        let url = format!("{}{}", self.base_url, path);
216        debug!("Fetching response text from {}", url);
217        let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
218        Ok(response)
219    }
220
221    /// Sleeps out the current backoff and charges an attempt, or reports that
222    /// the retry budget is spent so the caller surfaces the error it is holding.
223    async fn backoff(&self, attempts: &mut usize, delay: &mut Duration) -> bool {
224        if *attempts >= self.max_retries {
225            return false;
226        }
227        tokio::time::sleep(*delay).await;
228        *attempts = attempts.saturating_add(1);
229        *delay = delay.saturating_mul(2);
230        true
231    }
232
233    async fn get_with_retry(
234        &self,
235        url: &str,
236        client: &dyn HttpClient,
237    ) -> Result<(String, u16), ChainServiceError> {
238        within_timeout(self.total_timeout, self.get_attempts(url, client)).await
239    }
240
241    async fn get_attempts(
242        &self,
243        url: &str,
244        client: &dyn HttpClient,
245    ) -> Result<(String, u16), ChainServiceError> {
246        let mut delay = BASE_BACKOFF_MILLIS;
247        let mut attempts = 0;
248
249        loop {
250            let mut headers = HashMap::new();
251            if let Some(basic_auth) = &self.basic_auth {
252                add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
253            }
254
255            let response = match client.get(url.to_string(), Some(headers)).await {
256                Ok(response) => response,
257                Err(e) => {
258                    if is_transport_retryable(&e) && self.backoff(&mut attempts, &mut delay).await {
259                        continue;
260                    }
261                    return Err(e.into());
262                }
263            };
264
265            let HttpResponse { body, status, .. } = response;
266            if is_status_retryable(status) && self.backoff(&mut attempts, &mut delay).await {
267                continue;
268            }
269            if !(200..300).contains(&status) {
270                return Err(HttpError::Status { status, body }.into());
271            }
272            return Ok((body, status));
273        }
274    }
275
276    async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
277        within_timeout(self.total_timeout, self.post_attempts(url, body)).await
278    }
279
280    async fn post_attempts(
281        &self,
282        url: &str,
283        body: Option<String>,
284    ) -> Result<String, ChainServiceError> {
285        info!("Posting to {}", url);
286        debug!(
287            "Posting to {} with body {}",
288            url,
289            body.clone().unwrap_or_default()
290        );
291
292        let mut delay = BASE_BACKOFF_MILLIS;
293        let mut attempts = 0;
294
295        loop {
296            let mut headers: HashMap<String, String> = HashMap::new();
297            add_content_type_header(&mut headers, ContentType::TextPlain);
298            if let Some(basic_auth) = &self.basic_auth {
299                add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
300            }
301
302            let response = match self
303                .client
304                .post(url.to_string(), Some(headers), body.clone())
305                .await
306            {
307                Ok(response) => response,
308                Err(e) => {
309                    if is_transport_retryable(&e) && self.backoff(&mut attempts, &mut delay).await {
310                        continue;
311                    }
312                    return Err(e.into());
313                }
314            };
315
316            let HttpResponse { body, status, .. } = response;
317            if is_status_retryable(status) && self.backoff(&mut attempts, &mut delay).await {
318                continue;
319            }
320            if !(200..300).contains(&status) {
321                return Err(HttpError::Status { status, body }.into());
322            }
323            return Ok(body);
324        }
325    }
326
327    async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
328        let fee_map = self
329            .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
330            .await?;
331        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
332        let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
333
334        Ok(RecommendedFees {
335            fastest_fee: get_fees(&1),
336            half_hour_fee: get_fees(&3),
337            hour_fee: get_fees(&6),
338            economy_fee: get_fees(&25),
339            minimum_fee: get_fees(&1008),
340        })
341    }
342
343    async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
344        let response = self
345            .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
346            .await?;
347        Ok(response.into())
348    }
349
350    // ---- BitcoinChainService method bodies (run on the captured runtime
351    //      via the outer struct's `run_on_runtime` helper) ---------------
352
353    async fn do_get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
354        let address = address
355            .parse::<Address<NetworkUnchecked>>()?
356            .require_network(self.network.into())?;
357
358        let utxos = self
359            .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
360            .await?;
361
362        Ok(utxos)
363    }
364
365    async fn do_get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
366        let address = address
367            .parse::<Address<NetworkUnchecked>>()?
368            .require_network(self.network.into())?;
369        let address_str = address.to_string();
370
371        // A refund address only ever holds its one refund, a child paying that
372        // refund's fee and the sweep, so a single (un-paginated) page of history
373        // covers it.
374        let txs = self
375            .get_response_json::<Vec<AddressTx>>(format!("/address/{address}/txs").as_str())
376            .await?;
377        let mut txos = Vec::new();
378        for tx in txs {
379            for (vout, out) in tx.vout.iter().enumerate() {
380                if out.scriptpubkey_address.as_deref() != Some(address_str.as_str()) {
381                    continue;
382                }
383                txos.push(Utxo {
384                    txid: tx.txid.clone(),
385                    vout: u32::try_from(vout)
386                        .map_err(|_| ChainServiceError::Generic("vout overflow".to_string()))?,
387                    value: out.value,
388                    status: tx.status.clone(),
389                });
390            }
391        }
392        Ok(txos)
393    }
394
395    async fn do_get_transaction_status(
396        &self,
397        txid: String,
398    ) -> Result<super::TxStatus, ChainServiceError> {
399        let tx_info = self
400            .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
401            .await?;
402        Ok(tx_info.status)
403    }
404
405    async fn do_tip_height(&self) -> Result<u32, ChainServiceError> {
406        // Plain-text height, served by both esplora and mempool.space.
407        let height = self.get_response_text("/blocks/tip/height").await?;
408        height
409            .trim()
410            .parse()
411            .map_err(|_| ChainServiceError::Generic(format!("invalid tip height: {height}")))
412    }
413
414    async fn do_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
415        let tx = self
416            .get_response_text(format!("/tx/{txid}/hex").as_str())
417            .await?;
418        Ok(tx)
419    }
420
421    async fn do_get_outspend(
422        &self,
423        txid: String,
424        vout: u32,
425    ) -> Result<Outspend, ChainServiceError> {
426        let outspend = self
427            .get_response_json::<Outspend>(format!("/tx/{txid}/outspend/{vout}").as_str())
428            .await?;
429        Ok(outspend)
430    }
431
432    /// Safe to retry: a re-broadcast of a transaction the server did accept is
433    /// answered "already known" rather than acted on twice.
434    async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
435        let url = format!("{}{}", self.base_url, "/tx");
436        self.post(&url, Some(tx)).await?;
437        Ok(())
438    }
439
440    async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
441        match self.api_type {
442            ChainApiType::Esplora => self.recommended_fees_esplora().await,
443            ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
444        }
445    }
446}
447
448#[macros::async_trait]
449impl BitcoinChainService for RestClientChainService {
450    async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
451        self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
452            .await
453    }
454
455    async fn get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
456        self.run_on_runtime(|inner| async move { inner.do_get_address_txos(address).await })
457            .await
458    }
459
460    async fn get_transaction_status(
461        &self,
462        txid: String,
463    ) -> Result<super::TxStatus, ChainServiceError> {
464        self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
465            .await
466    }
467
468    async fn tip_height(&self) -> Result<u32, ChainServiceError> {
469        self.run_on_runtime(|inner| async move { inner.do_tip_height().await })
470            .await
471    }
472
473    async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
474        self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
475            .await
476    }
477
478    async fn get_outspend(&self, txid: String, vout: u32) -> Result<Outspend, ChainServiceError> {
479        self.run_on_runtime(move |inner| async move { inner.do_get_outspend(txid, vout).await })
480            .await
481    }
482
483    async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
484        self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
485            .await
486    }
487
488    async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
489        self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
490            .await
491    }
492}
493
494fn is_status_retryable(status: u16) -> bool {
495    RETRYABLE_ERROR_CODES.contains(&status)
496}
497
498fn is_transport_retryable(error: &HttpError) -> bool {
499    matches!(error, HttpError::Request(_))
500}
501
502/// Fails `work` once `timeout` has elapsed, whatever attempt it is on.
503async fn within_timeout<T>(
504    timeout: Duration,
505    work: impl std::future::Future<Output = Result<T, ChainServiceError>>,
506) -> Result<T, ChainServiceError> {
507    match tokio::time::timeout(timeout, work).await {
508        Ok(result) => result,
509        Err(_) => Err(HttpError::Timeout(format!(
510            "gave up after {}s of attempts",
511            timeout.as_secs()
512        ))
513        .into()),
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::Network;
521    use std::sync::Mutex;
522
523    use macros::async_test_all;
524
525    #[cfg(feature = "browser-tests")]
526    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
527
528    #[cfg(test)]
529    use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
530
531    #[async_test_all]
532    async fn test_get_address_utxos() {
533        // Mock JSON response from the actual API call
534        let mock_response = r#"[
535            {
536                "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
537                "vout": 3,
538                "status": {
539                    "confirmed": true,
540                    "block_height": 725850,
541                    "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
542                    "block_time": 1646382740
543                },
544                "value": 24201
545            },
546            {
547                "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
548                "vout": 0,
549                "status": {
550                    "confirmed": true,
551                    "block_height": 840719,
552                    "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
553                    "block_time": 1713994081
554                },
555                "value": 30236
556            },
557            {
558                "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
559                "vout": 74,
560                "status": {
561                    "confirmed": true,
562                    "block_height": 726892,
563                    "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
564                    "block_time": 1647033214
565                },
566                "value": 5155
567            },
568            {
569                "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
570                "vout": 0,
571                "status": {
572                    "confirmed": true,
573                    "block_height": 857808,
574                    "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
575                    "block_time": 1724272387
576                },
577                "value": 6127
578            },
579            {
580                "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
581                "vout": 126,
582                "status": {
583                    "confirmed": true,
584                    "block_height": 748177,
585                    "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
586                    "block_time": 1659763398
587                },
588                "value": 22190
589            }
590        ]"#;
591
592        let mock = MockRestClient::new();
593        mock.add_response(MockResponse::new(200, mock_response.to_string()));
594
595        // Create the service with the mock server URL
596        let service = RestClientChainService::new(
597            "http://localhost:8080".to_string(),
598            Network::Mainnet,
599            3,
600            Arc::new(mock),
601            None,
602            ChainApiType::Esplora,
603        );
604
605        // Call the method under test
606        let mut result = service
607            .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
608            .await
609            .unwrap();
610
611        // Sort results by value for consistent testing
612        result.sort_by_key(|a| a.value);
613
614        // Verify we got the expected number of UTXOs
615        assert_eq!(result.len(), 5);
616
617        // Verify the UTXOs are correctly parsed and sorted by value
618        assert_eq!(result[0].value, 5155); // Smallest value
619        assert_eq!(
620            result[0].txid,
621            "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
622        );
623        assert_eq!(result[0].vout, 74);
624        assert!(result[0].status.confirmed);
625        assert_eq!(result[0].status.block_height, Some(726_892));
626
627        assert_eq!(result[1].value, 6127);
628        assert_eq!(
629            result[1].txid,
630            "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
631        );
632
633        assert_eq!(result[2].value, 22190);
634        assert_eq!(result[3].value, 24201);
635        assert_eq!(result[4].value, 30236); // Largest value
636
637        // Verify all UTXOs are confirmed
638        for utxo in &result {
639            assert!(utxo.status.confirmed);
640            assert!(utxo.status.block_height.is_some());
641            assert!(utxo.status.block_time.is_some());
642        }
643    }
644
645    #[async_test_all]
646    async fn test_get_address_txos_returns_spent_outputs() {
647        let address = "1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv";
648        // The refund tx pays the address (vout 0); a later sweep spends it and pays
649        // elsewhere. get_address_txos must still surface the refund output.
650        let mock_response = format!(
651            r#"[
652                {{
653                    "txid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
654                    "status": {{ "confirmed": false }},
655                    "vout": [
656                        {{ "scriptpubkey_address": "1DestinationaaaaaaaaaaaaaaaaaaaaZ", "value": 49000 }}
657                    ]
658                }},
659                {{
660                    "txid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
661                    "status": {{ "confirmed": true, "block_height": 800000, "block_time": 1700000000 }},
662                    "vout": [
663                        {{ "scriptpubkey_address": "{address}", "value": 50000 }},
664                        {{ "scriptpubkey_address": "1OtheraaaaaaaaaaaaaaaaaaaaaaaaaaZ", "value": 10000 }}
665                    ]
666                }}
667            ]"#
668        );
669
670        let mock = MockRestClient::new();
671        mock.add_response(MockResponse::new(200, mock_response));
672        let service = RestClientChainService::new(
673            "http://localhost:8080".to_string(),
674            Network::Mainnet,
675            3,
676            Arc::new(mock),
677            None,
678            ChainApiType::Esplora,
679        );
680
681        let result = service.get_address_txos(address.to_string()).await.unwrap();
682
683        assert_eq!(
684            result.len(),
685            1,
686            "only the output paying the address is kept"
687        );
688        assert_eq!(
689            result[0].txid,
690            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
691        );
692        assert_eq!(result[0].vout, 0);
693        assert_eq!(result[0].value, 50000);
694        assert!(result[0].status.confirmed);
695    }
696
697    /// Answers with `failures` transport errors before serving `response`.
698    struct FlakyTransport {
699        remaining_failures: Mutex<usize>,
700        attempts: Mutex<usize>,
701        error: HttpError,
702        response: String,
703    }
704
705    impl FlakyTransport {
706        fn new(failures: usize, error: HttpError, response: &str) -> Self {
707            Self {
708                remaining_failures: Mutex::new(failures),
709                attempts: Mutex::new(0),
710                error,
711                response: response.to_string(),
712            }
713        }
714
715        fn answer(&self) -> Result<HttpResponse, HttpError> {
716            {
717                let mut attempts = self.attempts.lock().unwrap();
718                *attempts = attempts.saturating_add(1);
719            }
720            let mut remaining = self.remaining_failures.lock().unwrap();
721            if *remaining > 0 {
722                *remaining = remaining.saturating_sub(1);
723                return Err(self.error.clone());
724            }
725            Ok(HttpResponse {
726                status: 200,
727                body: self.response.clone(),
728                headers: HashMap::new(),
729            })
730        }
731    }
732
733    fn broken_pipe() -> HttpError {
734        HttpError::Request(
735            "client error (SendRequest) : connection error : stream closed because of a \
736             broken pipe"
737                .to_string(),
738        )
739    }
740
741    #[macros::async_trait]
742    impl HttpClient for FlakyTransport {
743        async fn get(
744            &self,
745            _url: String,
746            _headers: Option<HashMap<String, String>>,
747        ) -> Result<HttpResponse, HttpError> {
748            self.answer()
749        }
750
751        async fn post(
752            &self,
753            _url: String,
754            _headers: Option<HashMap<String, String>>,
755            _body: Option<String>,
756        ) -> Result<HttpResponse, HttpError> {
757            self.answer()
758        }
759
760        async fn delete(
761            &self,
762            _url: String,
763            _headers: Option<HashMap<String, String>>,
764            _body: Option<String>,
765        ) -> Result<HttpResponse, HttpError> {
766            self.answer()
767        }
768    }
769
770    fn flaky_service(
771        failures: usize,
772        error: HttpError,
773        response: &str,
774    ) -> (RestClientChainService, Arc<FlakyTransport>) {
775        let transport = Arc::new(FlakyTransport::new(failures, error, response));
776        let service = RestClientChainService::new(
777            "http://localhost:8080".to_string(),
778            Network::Mainnet,
779            3,
780            transport.clone(),
781            None,
782            ChainApiType::Esplora,
783        );
784        (service, transport)
785    }
786
787    const TX_STATUS_RESPONSE: &str = r#"{
788        "txid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
789        "status": { "confirmed": true, "block_height": 100 }
790    }"#;
791
792    #[async_test_all]
793    async fn test_get_retries_transport_errors() {
794        let (service, _) = flaky_service(3, broken_pipe(), TX_STATUS_RESPONSE);
795
796        let status = service
797            .get_transaction_status("aaaa".to_string())
798            .await
799            .unwrap();
800
801        assert!(status.confirmed);
802    }
803
804    #[async_test_all]
805    async fn test_get_gives_up_past_the_retry_budget() {
806        let (service, _) = flaky_service(4, broken_pipe(), TX_STATUS_RESPONSE);
807
808        let result = service.get_transaction_status("aaaa".to_string()).await;
809
810        assert!(matches!(
811            result,
812            Err(ChainServiceError::ServiceConnectivity(_))
813        ));
814    }
815
816    #[async_test_all]
817    async fn test_broadcast_retries_transport_errors() {
818        let (service, _) = flaky_service(3, broken_pipe(), "aaaa");
819
820        service
821            .broadcast_transaction("00".to_string())
822            .await
823            .unwrap();
824    }
825
826    /// A timeout has already cost the full per-request wait, so it is surfaced
827    /// rather than spent again.
828    #[async_test_all]
829    async fn test_get_does_not_retry_timeouts() {
830        let (service, transport) = flaky_service(
831            1,
832            HttpError::Timeout("operation timed out".to_string()),
833            TX_STATUS_RESPONSE,
834        );
835
836        let result = service.get_transaction_status("aaaa".to_string()).await;
837
838        assert!(matches!(
839            result,
840            Err(ChainServiceError::ServiceConnectivity(_))
841        ));
842        assert_eq!(*transport.attempts.lock().unwrap(), 1);
843    }
844
845    /// The timeout covers the whole call, so a transport that keeps failing
846    /// cannot stretch it by one more attempt.
847    #[async_test_all]
848    async fn test_timeout_bounds_the_whole_call() {
849        let slow = async {
850            tokio::time::sleep(Duration::from_secs(30)).await;
851            Ok::<_, ChainServiceError>(())
852        };
853
854        let result = within_timeout(Duration::from_millis(50), slow).await;
855
856        assert!(matches!(
857            result,
858            Err(ChainServiceError::ServiceConnectivity(_))
859        ));
860    }
861
862    /// A body over the size cap is refused identically every attempt, so it is
863    /// not worth another one.
864    #[async_test_all]
865    async fn test_get_does_not_retry_body_errors() {
866        let (service, transport) = flaky_service(
867            1,
868            HttpError::Body("response exceeds the 10485760 byte limit".to_string()),
869            TX_STATUS_RESPONSE,
870        );
871
872        let result = service.get_transaction_status("aaaa".to_string()).await;
873
874        assert!(matches!(
875            result,
876            Err(ChainServiceError::ServiceConnectivity(_))
877        ));
878        assert_eq!(*transport.attempts.lock().unwrap(), 1);
879    }
880}