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, .. } =
202 client.get(url.to_string(), Some(headers)).await?;
203 match status {
204 status if attempts < self.max_retries && is_status_retryable(status) => {
205 tokio::time::sleep(delay).await;
206 attempts = attempts.saturating_add(1);
207 delay = delay.saturating_mul(2);
208 }
209 _ => {
210 if !(200..300).contains(&status) {
211 return Err(HttpError::Status { status, body }.into());
212 }
213 return Ok((body, status));
214 }
215 }
216 }
217 }
218
219 async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
220 let mut headers: HashMap<String, String> = HashMap::new();
221 add_content_type_header(&mut headers, ContentType::TextPlain);
222 if let Some(basic_auth) = &self.basic_auth {
223 add_basic_auth_header(&mut headers, &basic_auth.username, &basic_auth.password);
224 }
225 info!(
226 "Posting to {} with body {} and headers {:?}",
227 url,
228 body.clone().unwrap_or_default(),
229 headers
230 );
231 let HttpResponse { body, status, .. } = self
232 .client
233 .post(url.to_string(), Some(headers), body)
234 .await?;
235 if !(200..300).contains(&status) {
236 return Err(HttpError::Status { status, body }.into());
237 }
238
239 Ok(body)
240 }
241
242 async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
243 let fee_map = self
244 .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
245 .await?;
246 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
247 let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
248
249 Ok(RecommendedFees {
250 fastest_fee: get_fees(&1),
251 half_hour_fee: get_fees(&3),
252 hour_fee: get_fees(&6),
253 economy_fee: get_fees(&25),
254 minimum_fee: get_fees(&1008),
255 })
256 }
257
258 async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
259 let response = self
260 .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
261 .await?;
262 Ok(response.into())
263 }
264
265 async fn do_get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
269 let address = address
270 .parse::<Address<NetworkUnchecked>>()?
271 .require_network(self.network.into())?;
272
273 let utxos = self
274 .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
275 .await?;
276
277 Ok(utxos)
278 }
279
280 async fn do_get_transaction_status(
281 &self,
282 txid: String,
283 ) -> Result<super::TxStatus, ChainServiceError> {
284 let tx_info = self
285 .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
286 .await?;
287 Ok(tx_info.status)
288 }
289
290 async fn do_get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
291 let tx = self
292 .get_response_text(format!("/tx/{txid}/hex").as_str())
293 .await?;
294 Ok(tx)
295 }
296
297 async fn do_broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
298 let url = format!("{}{}", self.base_url, "/tx");
299 self.post(&url, Some(tx)).await?;
300 Ok(())
301 }
302
303 async fn do_recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
304 match self.api_type {
305 ChainApiType::Esplora => self.recommended_fees_esplora().await,
306 ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
307 }
308 }
309}
310
311#[macros::async_trait]
312impl BitcoinChainService for RestClientChainService {
313 async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
314 self.run_on_runtime(|inner| async move { inner.do_get_address_utxos(address).await })
315 .await
316 }
317
318 async fn get_transaction_status(
319 &self,
320 txid: String,
321 ) -> Result<super::TxStatus, ChainServiceError> {
322 self.run_on_runtime(|inner| async move { inner.do_get_transaction_status(txid).await })
323 .await
324 }
325
326 async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
327 self.run_on_runtime(|inner| async move { inner.do_get_transaction_hex(txid).await })
328 .await
329 }
330
331 async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
332 self.run_on_runtime(|inner| async move { inner.do_broadcast_transaction(tx).await })
333 .await
334 }
335
336 async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
337 self.run_on_runtime(|inner| async move { inner.do_recommended_fees().await })
338 .await
339 }
340}
341
342fn is_status_retryable(status: u16) -> bool {
343 RETRYABLE_ERROR_CODES.contains(&status)
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::Network;
350
351 use macros::async_test_all;
352
353 #[cfg(feature = "browser-tests")]
354 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
355
356 #[cfg(test)]
357 use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
358
359 #[async_test_all]
360 async fn test_get_address_utxos() {
361 let mock_response = r#"[
363 {
364 "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
365 "vout": 3,
366 "status": {
367 "confirmed": true,
368 "block_height": 725850,
369 "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
370 "block_time": 1646382740
371 },
372 "value": 24201
373 },
374 {
375 "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
376 "vout": 0,
377 "status": {
378 "confirmed": true,
379 "block_height": 840719,
380 "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
381 "block_time": 1713994081
382 },
383 "value": 30236
384 },
385 {
386 "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
387 "vout": 74,
388 "status": {
389 "confirmed": true,
390 "block_height": 726892,
391 "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
392 "block_time": 1647033214
393 },
394 "value": 5155
395 },
396 {
397 "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
398 "vout": 0,
399 "status": {
400 "confirmed": true,
401 "block_height": 857808,
402 "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
403 "block_time": 1724272387
404 },
405 "value": 6127
406 },
407 {
408 "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
409 "vout": 126,
410 "status": {
411 "confirmed": true,
412 "block_height": 748177,
413 "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
414 "block_time": 1659763398
415 },
416 "value": 22190
417 }
418 ]"#;
419
420 let mock = MockRestClient::new();
421 mock.add_response(MockResponse::new(200, mock_response.to_string()));
422
423 let service = RestClientChainService::new(
425 "http://localhost:8080".to_string(),
426 Network::Mainnet,
427 3,
428 Arc::new(mock),
429 None,
430 ChainApiType::Esplora,
431 );
432
433 let mut result = service
435 .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
436 .await
437 .unwrap();
438
439 result.sort_by_key(|a| a.value);
441
442 assert_eq!(result.len(), 5);
444
445 assert_eq!(result[0].value, 5155); assert_eq!(
448 result[0].txid,
449 "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
450 );
451 assert_eq!(result[0].vout, 74);
452 assert!(result[0].status.confirmed);
453 assert_eq!(result[0].status.block_height, Some(726_892));
454
455 assert_eq!(result[1].value, 6127);
456 assert_eq!(
457 result[1].txid,
458 "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
459 );
460
461 assert_eq!(result[2].value, 22190);
462 assert_eq!(result[3].value, 24201);
463 assert_eq!(result[4].value, 30236); for utxo in &result {
467 assert!(utxo.status.confirmed);
468 assert!(utxo.status.block_height.is_some());
469 assert!(utxo.status.block_time.is_some());
470 }
471 }
472}