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, 500, 503, ];
26
27const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256);
29
30const 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#[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
76pub 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 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
155 runtime_handle: tokio::runtime::Handle::current(),
156 }
157 }
158
159 #[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 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 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 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 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 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
487async 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 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 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 let mut result = service
592 .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
593 .await
594 .unwrap();
595
596 result.sort_by_key(|a| a.value);
598
599 assert_eq!(result.len(), 5);
601
602 assert_eq!(result[0].value, 5155); 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); 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 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 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 #[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 #[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 #[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}