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::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 info!("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 info!("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!(
242 "Posting to {} with body {} and headers {:?}",
243 url,
244 body.clone().unwrap_or_default(),
245 headers
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_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
336 let tx = self
337 .get_response_text(format!("/tx/{txid}/hex").as_str())
338 .await?;
339 Ok(tx)
340 }
341
342 async fn do_get_outspend(
343 &self,
344 txid: String,
345 vout: u32,
346 ) -> Result<Outspend, ChainServiceError> {
347 let outspend = self
348 .get_response_json::<Outspend>(format!("/tx/{txid}/outspend/{vout}").as_str())
349 .await?;
350 Ok(outspend)
351 }
352
353 async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
354 let url = format!("{}{}", self.base_url, "/tx");
355 self.post(&url, Some(tx)).await?;
356 Ok(())
357 }
358
359 async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
360 match self.api_type {
361 ChainApiType::Esplora => self.recommended_fees_esplora().await,
362 ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
363 }
364 }
365}
366
367#[macros::async_trait]
368impl BitcoinChainService for RestClientChainService {
369 async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
370 self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
371 .await
372 }
373
374 async fn get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
375 self.run_on_runtime(|inner| async move { inner.do_get_address_txos(address).await })
376 .await
377 }
378
379 async fn get_transaction_status(
380 &self,
381 txid: String,
382 ) -> Result<super::TxStatus, ChainServiceError> {
383 self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
384 .await
385 }
386
387 async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
388 self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
389 .await
390 }
391
392 async fn get_outspend(&self, txid: String, vout: u32) -> Result<Outspend, ChainServiceError> {
393 self.run_on_runtime(move |inner| async move { inner.do_get_outspend(txid, vout).await })
394 .await
395 }
396
397 async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
398 self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
399 .await
400 }
401
402 async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
403 self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
404 .await
405 }
406}
407
408fn is_status_retryable(status: u16) -> bool {
409 RETRYABLE_ERROR_CODES.contains(&status)
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::Network;
416
417 use macros::async_test_all;
418
419 #[cfg(feature = "browser-tests")]
420 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
421
422 #[cfg(test)]
423 use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
424
425 #[async_test_all]
426 async fn test_get_address_utxos() {
427 let mock_response = r#"[
429 {
430 "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
431 "vout": 3,
432 "status": {
433 "confirmed": true,
434 "block_height": 725850,
435 "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
436 "block_time": 1646382740
437 },
438 "value": 24201
439 },
440 {
441 "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
442 "vout": 0,
443 "status": {
444 "confirmed": true,
445 "block_height": 840719,
446 "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
447 "block_time": 1713994081
448 },
449 "value": 30236
450 },
451 {
452 "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
453 "vout": 74,
454 "status": {
455 "confirmed": true,
456 "block_height": 726892,
457 "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
458 "block_time": 1647033214
459 },
460 "value": 5155
461 },
462 {
463 "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
464 "vout": 0,
465 "status": {
466 "confirmed": true,
467 "block_height": 857808,
468 "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
469 "block_time": 1724272387
470 },
471 "value": 6127
472 },
473 {
474 "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
475 "vout": 126,
476 "status": {
477 "confirmed": true,
478 "block_height": 748177,
479 "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
480 "block_time": 1659763398
481 },
482 "value": 22190
483 }
484 ]"#;
485
486 let mock = MockRestClient::new();
487 mock.add_response(MockResponse::new(200, mock_response.to_string()));
488
489 let service = RestClientChainService::new(
491 "http://localhost:8080".to_string(),
492 Network::Mainnet,
493 3,
494 Arc::new(mock),
495 None,
496 ChainApiType::Esplora,
497 );
498
499 let mut result = service
501 .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
502 .await
503 .unwrap();
504
505 result.sort_by_key(|a| a.value);
507
508 assert_eq!(result.len(), 5);
510
511 assert_eq!(result[0].value, 5155); assert_eq!(
514 result[0].txid,
515 "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
516 );
517 assert_eq!(result[0].vout, 74);
518 assert!(result[0].status.confirmed);
519 assert_eq!(result[0].status.block_height, Some(726_892));
520
521 assert_eq!(result[1].value, 6127);
522 assert_eq!(
523 result[1].txid,
524 "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
525 );
526
527 assert_eq!(result[2].value, 22190);
528 assert_eq!(result[3].value, 24201);
529 assert_eq!(result[4].value, 30236); for utxo in &result {
533 assert!(utxo.status.confirmed);
534 assert!(utxo.status.block_height.is_some());
535 assert!(utxo.status.block_time.is_some());
536 }
537 }
538
539 #[async_test_all]
540 async fn test_get_address_txos_returns_spent_outputs() {
541 let address = "1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv";
542 let mock_response = format!(
545 r#"[
546 {{
547 "txid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
548 "status": {{ "confirmed": false }},
549 "vout": [
550 {{ "scriptpubkey_address": "1DestinationaaaaaaaaaaaaaaaaaaaaZ", "value": 49000 }}
551 ]
552 }},
553 {{
554 "txid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
555 "status": {{ "confirmed": true, "block_height": 800000, "block_time": 1700000000 }},
556 "vout": [
557 {{ "scriptpubkey_address": "{address}", "value": 50000 }},
558 {{ "scriptpubkey_address": "1OtheraaaaaaaaaaaaaaaaaaaaaaaaaaZ", "value": 10000 }}
559 ]
560 }}
561 ]"#
562 );
563
564 let mock = MockRestClient::new();
565 mock.add_response(MockResponse::new(200, mock_response));
566 let service = RestClientChainService::new(
567 "http://localhost:8080".to_string(),
568 Network::Mainnet,
569 3,
570 Arc::new(mock),
571 None,
572 ChainApiType::Esplora,
573 );
574
575 let result = service.get_address_txos(address.to_string()).await.unwrap();
576
577 assert_eq!(
578 result.len(),
579 1,
580 "only the output paying the address is kept"
581 );
582 assert_eq!(
583 result[0].txid,
584 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
585 );
586 assert_eq!(result[0].vout, 0);
587 assert_eq!(result[0].value, 50000);
588 assert!(result[0].status.confirmed);
589 }
590}