1use bitcoin::{Address, address::NetworkUnchecked};
2use platform_utils::tokio;
3use platform_utils::{
4 ContentType, HttpClient, HttpError, HttpResponse, add_basic_auth_header,
5 add_content_type_header,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::Duration;
11use tracing::{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
30#[derive(Serialize, Deserialize, Clone)]
31struct TxInfo {
32 txid: String,
33 status: super::TxStatus,
34}
35
36#[derive(Deserialize)]
39struct AddressTx {
40 txid: String,
41 status: super::TxStatus,
42 vout: Vec<AddressTxVout>,
43}
44
45#[derive(Deserialize)]
46struct AddressTxVout {
47 #[serde(default)]
48 scriptpubkey_address: Option<String>,
49 value: u64,
50}
51
52pub struct BasicAuth {
53 username: String,
54 password: String,
55}
56
57impl BasicAuth {
58 pub fn new(username: String, password: String) -> Self {
59 Self { username, password }
60 }
61}
62
63struct RestClientChainServiceInner {
64 base_url: String,
65 network: Network,
66 client: Arc<dyn HttpClient>,
67 max_retries: usize,
68 basic_auth: Option<BasicAuth>,
69 api_type: ChainApiType,
70}
71
72pub struct RestClientChainService {
91 inner: Arc<RestClientChainServiceInner>,
92 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
93 runtime_handle: tokio::runtime::Handle,
94}
95
96#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
97#[derive(Clone, Copy, Debug)]
98pub enum ChainApiType {
99 Esplora,
100 MempoolSpace,
101}
102
103#[derive(Deserialize)]
104#[serde(rename_all = "camelCase")]
105struct MempoolSpaceRecommendedFeesResponse {
106 fastest_fee: f64,
107 half_hour_fee: f64,
108 hour_fee: f64,
109 economy_fee: f64,
110 minimum_fee: f64,
111}
112
113impl From<MempoolSpaceRecommendedFeesResponse> for RecommendedFees {
114 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
115 fn from(response: MempoolSpaceRecommendedFeesResponse) -> Self {
116 Self {
117 fastest_fee: response.fastest_fee.ceil() as u64,
118 half_hour_fee: response.half_hour_fee.ceil() as u64,
119 hour_fee: response.hour_fee.ceil() as u64,
120 economy_fee: response.economy_fee.ceil() as u64,
121 minimum_fee: response.minimum_fee.ceil() as u64,
122 }
123 }
124}
125
126impl RestClientChainService {
127 pub fn new(
128 base_url: String,
129 network: Network,
130 max_retries: usize,
131 http_client: Arc<dyn HttpClient>,
132 basic_auth: Option<BasicAuth>,
133 api_type: ChainApiType,
134 ) -> Self {
135 Self {
136 inner: Arc::new(RestClientChainServiceInner {
137 base_url,
138 network,
139 client: http_client,
140 max_retries,
141 basic_auth,
142 api_type,
143 }),
144 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
151 runtime_handle: tokio::runtime::Handle::current(),
152 }
153 }
154
155 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
158 async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
159 where
160 F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut + Send + 'static,
161 Fut: std::future::Future<Output = Result<T, ChainServiceError>> + Send,
162 T: Send + 'static,
163 {
164 let inner = self.inner.clone();
165 self.runtime_handle
166 .spawn(async move { work(inner).await })
167 .await
168 .map_err(|e| ChainServiceError::Generic(format!("join error: {e}")))?
169 }
170
171 #[cfg(all(target_family = "wasm", target_os = "unknown"))]
172 async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
173 where
174 F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut,
175 Fut: std::future::Future<Output = Result<T, ChainServiceError>>,
176 {
177 work(self.inner.clone()).await
178 }
179}
180
181impl RestClientChainServiceInner {
182 async fn get_response_json<T: serde::de::DeserializeOwned>(
183 &self,
184 path: &str,
185 ) -> Result<T, ChainServiceError> {
186 let url = format!("{}{}", self.base_url, path);
187 debug!("Fetching response json from {}", url);
188 let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
189
190 let response: T = serde_json::from_str(&response)
191 .map_err(|e| ChainServiceError::Generic(e.to_string()))?;
192
193 Ok(response)
194 }
195
196 async fn get_response_text(&self, path: &str) -> Result<String, ChainServiceError> {
197 let url = format!("{}{}", self.base_url, path);
198 debug!("Fetching response text from {}", url);
199 let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
200 Ok(response)
201 }
202
203 async fn get_with_retry(
204 &self,
205 url: &str,
206 client: &dyn HttpClient,
207 ) -> Result<(String, u16), ChainServiceError> {
208 let mut delay = BASE_BACKOFF_MILLIS;
209 let mut attempts = 0;
210
211 loop {
212 let mut headers = HashMap::new();
213 if let Some(basic_auth) = &self.basic_auth {
214 add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
215 }
216
217 let HttpResponse { body, status, .. } =
218 client.get(url.to_string(), Some(headers)).await?;
219 match status {
220 status if attempts < self.max_retries && is_status_retryable(status) => {
221 tokio::time::sleep(delay).await;
222 attempts = attempts.saturating_add(1);
223 delay = delay.saturating_mul(2);
224 }
225 _ => {
226 if !(200..300).contains(&status) {
227 return Err(HttpError::Status { status, body }.into());
228 }
229 return Ok((body, status));
230 }
231 }
232 }
233 }
234
235 async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
236 let mut headers: HashMap<String, String> = HashMap::new();
237 add_content_type_header(&mut headers, ContentType::TextPlain);
238 if let Some(basic_auth) = &self.basic_auth {
239 add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
240 }
241 info!("Posting to {}", url);
242 debug!(
243 "Posting to {} with body {}",
244 url,
245 body.clone().unwrap_or_default()
246 );
247 let HttpResponse { body, status, .. } = self
248 .client
249 .post(url.to_string(), Some(headers), body)
250 .await?;
251 if !(200..300).contains(&status) {
252 return Err(HttpError::Status { status, body }.into());
253 }
254
255 Ok(body)
256 }
257
258 async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
259 let fee_map = self
260 .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
261 .await?;
262 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
263 let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
264
265 Ok(RecommendedFees {
266 fastest_fee: get_fees(&1),
267 half_hour_fee: get_fees(&3),
268 hour_fee: get_fees(&6),
269 economy_fee: get_fees(&25),
270 minimum_fee: get_fees(&1008),
271 })
272 }
273
274 async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
275 let response = self
276 .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
277 .await?;
278 Ok(response.into())
279 }
280
281 async fn do_get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
285 let address = address
286 .parse::<Address<NetworkUnchecked>>()?
287 .require_network(self.network.into())?;
288
289 let utxos = self
290 .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
291 .await?;
292
293 Ok(utxos)
294 }
295
296 async fn do_get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
297 let address = address
298 .parse::<Address<NetworkUnchecked>>()?
299 .require_network(self.network.into())?;
300 let address_str = address.to_string();
301
302 let txs = self
305 .get_response_json::<Vec<AddressTx>>(format!("/address/{address}/txs").as_str())
306 .await?;
307 let mut txos = Vec::new();
308 for tx in txs {
309 for (vout, out) in tx.vout.iter().enumerate() {
310 if out.scriptpubkey_address.as_deref() != Some(address_str.as_str()) {
311 continue;
312 }
313 txos.push(Utxo {
314 txid: tx.txid.clone(),
315 vout: u32::try_from(vout)
316 .map_err(|_| ChainServiceError::Generic("vout overflow".to_string()))?,
317 value: out.value,
318 status: tx.status.clone(),
319 });
320 }
321 }
322 Ok(txos)
323 }
324
325 async fn do_get_transaction_status(
326 &self,
327 txid: String,
328 ) -> Result<super::TxStatus, ChainServiceError> {
329 let tx_info = self
330 .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
331 .await?;
332 Ok(tx_info.status)
333 }
334
335 async fn do_tip_height(&self) -> Result<u32, ChainServiceError> {
336 let height = self.get_response_text("/blocks/tip/height").await?;
338 height
339 .trim()
340 .parse()
341 .map_err(|_| ChainServiceError::Generic(format!("invalid tip height: {height}")))
342 }
343
344 async fn do_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
345 let tx = self
346 .get_response_text(format!("/tx/{txid}/hex").as_str())
347 .await?;
348 Ok(tx)
349 }
350
351 async fn do_get_outspend(
352 &self,
353 txid: String,
354 vout: u32,
355 ) -> Result<Outspend, ChainServiceError> {
356 let outspend = self
357 .get_response_json::<Outspend>(format!("/tx/{txid}/outspend/{vout}").as_str())
358 .await?;
359 Ok(outspend)
360 }
361
362 async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
363 let url = format!("{}{}", self.base_url, "/tx");
364 self.post(&url, Some(tx)).await?;
365 Ok(())
366 }
367
368 async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
369 match self.api_type {
370 ChainApiType::Esplora => self.recommended_fees_esplora().await,
371 ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
372 }
373 }
374}
375
376#[macros::async_trait]
377impl BitcoinChainService for RestClientChainService {
378 async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
379 self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
380 .await
381 }
382
383 async fn get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
384 self.run_on_runtime(|inner| async move { inner.do_get_address_txos(address).await })
385 .await
386 }
387
388 async fn get_transaction_status(
389 &self,
390 txid: String,
391 ) -> Result<super::TxStatus, ChainServiceError> {
392 self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
393 .await
394 }
395
396 async fn tip_height(&self) -> Result<u32, ChainServiceError> {
397 self.run_on_runtime(|inner| async move { inner.do_tip_height().await })
398 .await
399 }
400
401 async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
402 self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
403 .await
404 }
405
406 async fn get_outspend(&self, txid: String, vout: u32) -> Result<Outspend, ChainServiceError> {
407 self.run_on_runtime(move |inner| async move { inner.do_get_outspend(txid, vout).await })
408 .await
409 }
410
411 async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
412 self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
413 .await
414 }
415
416 async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
417 self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
418 .await
419 }
420}
421
422fn is_status_retryable(status: u16) -> bool {
423 RETRYABLE_ERROR_CODES.contains(&status)
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::Network;
430
431 use macros::async_test_all;
432
433 #[cfg(feature = "browser-tests")]
434 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
435
436 #[cfg(test)]
437 use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
438
439 #[async_test_all]
440 async fn test_get_address_utxos() {
441 let mock_response = r#"[
443 {
444 "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
445 "vout": 3,
446 "status": {
447 "confirmed": true,
448 "block_height": 725850,
449 "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
450 "block_time": 1646382740
451 },
452 "value": 24201
453 },
454 {
455 "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
456 "vout": 0,
457 "status": {
458 "confirmed": true,
459 "block_height": 840719,
460 "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
461 "block_time": 1713994081
462 },
463 "value": 30236
464 },
465 {
466 "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
467 "vout": 74,
468 "status": {
469 "confirmed": true,
470 "block_height": 726892,
471 "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
472 "block_time": 1647033214
473 },
474 "value": 5155
475 },
476 {
477 "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
478 "vout": 0,
479 "status": {
480 "confirmed": true,
481 "block_height": 857808,
482 "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
483 "block_time": 1724272387
484 },
485 "value": 6127
486 },
487 {
488 "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
489 "vout": 126,
490 "status": {
491 "confirmed": true,
492 "block_height": 748177,
493 "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
494 "block_time": 1659763398
495 },
496 "value": 22190
497 }
498 ]"#;
499
500 let mock = MockRestClient::new();
501 mock.add_response(MockResponse::new(200, mock_response.to_string()));
502
503 let service = RestClientChainService::new(
505 "http://localhost:8080".to_string(),
506 Network::Mainnet,
507 3,
508 Arc::new(mock),
509 None,
510 ChainApiType::Esplora,
511 );
512
513 let mut result = service
515 .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
516 .await
517 .unwrap();
518
519 result.sort_by_key(|a| a.value);
521
522 assert_eq!(result.len(), 5);
524
525 assert_eq!(result[0].value, 5155); assert_eq!(
528 result[0].txid,
529 "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
530 );
531 assert_eq!(result[0].vout, 74);
532 assert!(result[0].status.confirmed);
533 assert_eq!(result[0].status.block_height, Some(726_892));
534
535 assert_eq!(result[1].value, 6127);
536 assert_eq!(
537 result[1].txid,
538 "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
539 );
540
541 assert_eq!(result[2].value, 22190);
542 assert_eq!(result[3].value, 24201);
543 assert_eq!(result[4].value, 30236); for utxo in &result {
547 assert!(utxo.status.confirmed);
548 assert!(utxo.status.block_height.is_some());
549 assert!(utxo.status.block_time.is_some());
550 }
551 }
552
553 #[async_test_all]
554 async fn test_get_address_txos_returns_spent_outputs() {
555 let address = "1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv";
556 let mock_response = format!(
559 r#"[
560 {{
561 "txid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
562 "status": {{ "confirmed": false }},
563 "vout": [
564 {{ "scriptpubkey_address": "1DestinationaaaaaaaaaaaaaaaaaaaaZ", "value": 49000 }}
565 ]
566 }},
567 {{
568 "txid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
569 "status": {{ "confirmed": true, "block_height": 800000, "block_time": 1700000000 }},
570 "vout": [
571 {{ "scriptpubkey_address": "{address}", "value": 50000 }},
572 {{ "scriptpubkey_address": "1OtheraaaaaaaaaaaaaaaaaaaaaaaaaaZ", "value": 10000 }}
573 ]
574 }}
575 ]"#
576 );
577
578 let mock = MockRestClient::new();
579 mock.add_response(MockResponse::new(200, mock_response));
580 let service = RestClientChainService::new(
581 "http://localhost:8080".to_string(),
582 Network::Mainnet,
583 3,
584 Arc::new(mock),
585 None,
586 ChainApiType::Esplora,
587 );
588
589 let result = service.get_address_txos(address.to_string()).await.unwrap();
590
591 assert_eq!(
592 result.len(),
593 1,
594 "only the output paying the address is kept"
595 );
596 assert_eq!(
597 result[0].txid,
598 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
599 );
600 assert_eq!(result[0].vout, 0);
601 assert_eq!(result[0].value, 50000);
602 assert!(result[0].status.confirmed);
603 }
604}