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