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, 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
36pub struct BasicAuth {
37 username: String,
38 password: String,
39}
40
41impl BasicAuth {
42 pub fn new(username: String, password: String) -> Self {
43 Self { username, password }
44 }
45}
46
47struct RestClientChainServiceInner {
48 base_url: String,
49 network: Network,
50 client: Arc<dyn HttpClient>,
51 max_retries: usize,
52 basic_auth: Option<BasicAuth>,
53 api_type: ChainApiType,
54}
55
56pub struct RestClientChainService {
75 inner: Arc<RestClientChainServiceInner>,
76 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
77 runtime_handle: tokio::runtime::Handle,
78}
79
80#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
81#[derive(Clone, Copy, Debug)]
82pub enum ChainApiType {
83 Esplora,
84 MempoolSpace,
85}
86
87#[derive(Deserialize)]
88#[serde(rename_all = "camelCase")]
89struct MempoolSpaceRecommendedFeesResponse {
90 fastest_fee: f64,
91 half_hour_fee: f64,
92 hour_fee: f64,
93 economy_fee: f64,
94 minimum_fee: f64,
95}
96
97impl From<MempoolSpaceRecommendedFeesResponse> for RecommendedFees {
98 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
99 fn from(response: MempoolSpaceRecommendedFeesResponse) -> Self {
100 Self {
101 fastest_fee: response.fastest_fee.ceil() as u64,
102 half_hour_fee: response.half_hour_fee.ceil() as u64,
103 hour_fee: response.hour_fee.ceil() as u64,
104 economy_fee: response.economy_fee.ceil() as u64,
105 minimum_fee: response.minimum_fee.ceil() as u64,
106 }
107 }
108}
109
110impl RestClientChainService {
111 pub fn new(
112 base_url: String,
113 network: Network,
114 max_retries: usize,
115 http_client: Arc<dyn HttpClient>,
116 basic_auth: Option<BasicAuth>,
117 api_type: ChainApiType,
118 ) -> Self {
119 Self {
120 inner: Arc::new(RestClientChainServiceInner {
121 base_url,
122 network,
123 client: http_client,
124 max_retries,
125 basic_auth,
126 api_type,
127 }),
128 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
135 runtime_handle: tokio::runtime::Handle::current(),
136 }
137 }
138
139 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
142 async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
143 where
144 F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut + Send + 'static,
145 Fut: std::future::Future<Output = Result<T, ChainServiceError>> + Send,
146 T: Send + 'static,
147 {
148 let inner = self.inner.clone();
149 self.runtime_handle
150 .spawn(async move { work(inner).await })
151 .await
152 .map_err(|e| ChainServiceError::Generic(format!("join error: {e}")))?
153 }
154
155 #[cfg(all(target_family = "wasm", target_os = "unknown"))]
156 async fn run_on_runtime<F, Fut, T>(&self, work: F) -> Result<T, ChainServiceError>
157 where
158 F: FnOnce(Arc<RestClientChainServiceInner>) -> Fut,
159 Fut: std::future::Future<Output = Result<T, ChainServiceError>>,
160 {
161 work(self.inner.clone()).await
162 }
163}
164
165impl RestClientChainServiceInner {
166 async fn get_response_json<T: serde::de::DeserializeOwned>(
167 &self,
168 path: &str,
169 ) -> Result<T, ChainServiceError> {
170 let url = format!("{}{}", self.base_url, path);
171 info!("Fetching response json from {}", url);
172 let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
173
174 let response: T = serde_json::from_str(&response)
175 .map_err(|e| ChainServiceError::Generic(e.to_string()))?;
176
177 Ok(response)
178 }
179
180 async fn get_response_text(&self, path: &str) -> Result<String, ChainServiceError> {
181 let url = format!("{}{}", self.base_url, path);
182 info!("Fetching response text from {}", url);
183 let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
184 Ok(response)
185 }
186
187 async fn get_with_retry(
188 &self,
189 url: &str,
190 client: &dyn HttpClient,
191 ) -> Result<(String, u16), ChainServiceError> {
192 let mut delay = BASE_BACKOFF_MILLIS;
193 let mut attempts = 0;
194
195 loop {
196 let mut headers = HashMap::new();
197 if let Some(basic_auth) = &self.basic_auth {
198 add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
199 }
200
201 let HttpResponse { body, status } = client.get(url.to_string(), Some(headers)).await?;
202 match status {
203 status if attempts < self.max_retries && is_status_retryable(status) => {
204 tokio::time::sleep(delay).await;
205 attempts = attempts.saturating_add(1);
206 delay = delay.saturating_mul(2);
207 }
208 _ => {
209 if !(200..300).contains(&status) {
210 return Err(HttpError::Status { status, body }.into());
211 }
212 return Ok((body, status));
213 }
214 }
215 }
216 }
217
218 async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
219 let mut headers: HashMap<String, String> = HashMap::new();
220 add_content_type_header(&mut headers, ContentType::TextPlain);
221 if let Some(basic_auth) = &self.basic_auth {
222 add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
223 }
224 info!(
225 "Posting to {} with body {} and headers {:?}",
226 url,
227 body.clone().unwrap_or_default(),
228 headers
229 );
230 let HttpResponse { body, status } = self
231 .client
232 .post(url.to_string(), Some(headers), body)
233 .await?;
234 if !(200..300).contains(&status) {
235 return Err(HttpError::Status { status, body }.into());
236 }
237
238 Ok(body)
239 }
240
241 async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
242 let fee_map = self
243 .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
244 .await?;
245 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
246 let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
247
248 Ok(RecommendedFees {
249 fastest_fee: get_fees(&1),
250 half_hour_fee: get_fees(&3),
251 hour_fee: get_fees(&6),
252 economy_fee: get_fees(&25),
253 minimum_fee: get_fees(&1008),
254 })
255 }
256
257 async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
258 let response = self
259 .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
260 .await?;
261 Ok(response.into())
262 }
263
264 async fn do_get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
268 let address = address
269 .parse::<Address<NetworkUnchecked>>()?
270 .require_network(self.network.into())?;
271
272 let utxos = self
273 .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
274 .await?;
275
276 Ok(utxos)
277 }
278
279 async fn do_get_transaction_status(
280 &self,
281 txid: String,
282 ) -> Result<super::TxStatus, ChainServiceError> {
283 let tx_info = self
284 .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
285 .await?;
286 Ok(tx_info.status)
287 }
288
289 async fn do_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
290 let tx = self
291 .get_response_text(format!("/tx/{txid}/hex").as_str())
292 .await?;
293 Ok(tx)
294 }
295
296 async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
297 let url = format!("{}{}", self.base_url, "/tx");
298 self.post(&url, Some(tx)).await?;
299 Ok(())
300 }
301
302 async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
303 match self.api_type {
304 ChainApiType::Esplora => self.recommended_fees_esplora().await,
305 ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
306 }
307 }
308}
309
310#[macros::async_trait]
311impl BitcoinChainService for RestClientChainService {
312 async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
313 self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
314 .await
315 }
316
317 async fn get_transaction_status(
318 &self,
319 txid: String,
320 ) -> Result<super::TxStatus, ChainServiceError> {
321 self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
322 .await
323 }
324
325 async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
326 self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
327 .await
328 }
329
330 async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
331 self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
332 .await
333 }
334
335 async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
336 self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
337 .await
338 }
339}
340
341fn is_status_retryable(status: u16) -> bool {
342 RETRYABLE_ERROR_CODES.contains(&status)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::Network;
349
350 use macros::async_test_all;
351
352 #[cfg(feature = "browser-tests")]
353 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
354
355 #[cfg(test)]
356 use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
357
358 #[async_test_all]
359 async fn test_get_address_utxos() {
360 let mock_response = r#"[
362 {
363 "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
364 "vout": 3,
365 "status": {
366 "confirmed": true,
367 "block_height": 725850,
368 "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
369 "block_time": 1646382740
370 },
371 "value": 24201
372 },
373 {
374 "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
375 "vout": 0,
376 "status": {
377 "confirmed": true,
378 "block_height": 840719,
379 "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
380 "block_time": 1713994081
381 },
382 "value": 30236
383 },
384 {
385 "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
386 "vout": 74,
387 "status": {
388 "confirmed": true,
389 "block_height": 726892,
390 "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
391 "block_time": 1647033214
392 },
393 "value": 5155
394 },
395 {
396 "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
397 "vout": 0,
398 "status": {
399 "confirmed": true,
400 "block_height": 857808,
401 "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
402 "block_time": 1724272387
403 },
404 "value": 6127
405 },
406 {
407 "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
408 "vout": 126,
409 "status": {
410 "confirmed": true,
411 "block_height": 748177,
412 "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
413 "block_time": 1659763398
414 },
415 "value": 22190
416 }
417 ]"#;
418
419 let mock = MockRestClient::new();
420 mock.add_response(MockResponse::new(200, mock_response.to_string()));
421
422 let service = RestClientChainService::new(
424 "http://localhost:8080".to_string(),
425 Network::Mainnet,
426 3,
427 Arc::new(mock),
428 None,
429 ChainApiType::Esplora,
430 );
431
432 let mut result = service
434 .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
435 .await
436 .unwrap();
437
438 result.sort_by_key(|a| a.value);
440
441 assert_eq!(result.len(), 5);
443
444 assert_eq!(result[0].value, 5155); assert_eq!(
447 result[0].txid,
448 "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
449 );
450 assert_eq!(result[0].vout, 74);
451 assert!(result[0].status.confirmed);
452 assert_eq!(result[0].status.block_height, Some(726_892));
453
454 assert_eq!(result[1].value, 6127);
455 assert_eq!(
456 result[1].txid,
457 "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
458 );
459
460 assert_eq!(result[2].value, 22190);
461 assert_eq!(result[3].value, 24201);
462 assert_eq!(result[4].value, 30236); for utxo in &result {
466 assert!(utxo.status.confirmed);
467 assert!(utxo.status.block_height.is_some());
468 assert!(utxo.status.block_time.is_some());
469 }
470 }
471}