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 total_timeout: Duration,
75}
76
77pub 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 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
157 runtime_handle: tokio::runtime::Handle::current(),
158 }
159 }
160
161 #[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 #[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 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 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 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 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 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
502async 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 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 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 let mut result = service
607 .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
608 .await
609 .unwrap();
610
611 result.sort_by_key(|a| a.value);
613
614 assert_eq!(result.len(), 5);
616
617 assert_eq!(result[0].value, 5155); 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); 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 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 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 #[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 #[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 #[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}