1use base64::{Engine as _, engine::general_purpose};
2use bitcoin::{Address, address::NetworkUnchecked};
3use breez_sdk_common::rest::RestClient as CommonRestClient;
4use breez_sdk_common::{error::ServiceConnectivityError, rest::RestResponse};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::time::Duration;
8use tokio_with_wasm::alias as tokio;
9use tracing::info;
10
11use crate::chain::RecommendedFees;
12use crate::{
13 Network,
14 chain::{ChainServiceError, Utxo},
15};
16
17use super::BitcoinChainService;
18
19pub const RETRYABLE_ERROR_CODES: [u16; 3] = [
20 429, 500, 503, ];
24
25const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256);
27
28#[derive(Serialize, Deserialize, Clone)]
29struct TxInfo {
30 txid: String,
31 status: super::TxStatus,
32}
33
34pub struct BasicAuth {
35 username: String,
36 password: String,
37}
38
39impl BasicAuth {
40 pub fn new(username: String, password: String) -> Self {
41 Self { username, password }
42 }
43}
44
45pub struct RestClientChainService {
46 base_url: String,
47 network: Network,
48 client: Box<dyn breez_sdk_common::rest::RestClient>,
49 max_retries: usize,
50 basic_auth: Option<BasicAuth>,
51 api_type: ChainApiType,
52}
53
54#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
55pub enum ChainApiType {
56 Esplora,
57 MempoolSpace,
58}
59
60#[derive(Deserialize)]
61#[serde(rename_all = "camelCase")]
62struct MempoolSpaceRecommendedFeesResponse {
63 fastest_fee: f64,
64 half_hour_fee: f64,
65 hour_fee: f64,
66 economy_fee: f64,
67 minimum_fee: f64,
68}
69
70impl From<MempoolSpaceRecommendedFeesResponse> for RecommendedFees {
71 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
72 fn from(response: MempoolSpaceRecommendedFeesResponse) -> Self {
73 Self {
74 fastest_fee: response.fastest_fee.ceil() as u64,
75 half_hour_fee: response.half_hour_fee.ceil() as u64,
76 hour_fee: response.hour_fee.ceil() as u64,
77 economy_fee: response.economy_fee.ceil() as u64,
78 minimum_fee: response.minimum_fee.ceil() as u64,
79 }
80 }
81}
82
83impl RestClientChainService {
84 pub fn new(
85 base_url: String,
86 network: Network,
87 max_retries: usize,
88 rest_client: Box<dyn CommonRestClient>,
89 basic_auth: Option<BasicAuth>,
90 api_type: ChainApiType,
91 ) -> Self {
92 Self {
93 base_url,
94 network,
95 client: rest_client,
96 max_retries,
97 basic_auth,
98 api_type,
99 }
100 }
101
102 async fn get_response_json<T: serde::de::DeserializeOwned>(
103 &self,
104 path: &str,
105 ) -> Result<T, ChainServiceError> {
106 let url = format!("{}{}", self.base_url, path);
107 info!("Fetching response json from {}", url);
108 let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
109
110 let response: T = serde_json::from_str(&response)
111 .map_err(|e| ChainServiceError::Generic(e.to_string()))?;
112
113 Ok(response)
114 }
115
116 async fn get_response_text(&self, path: &str) -> Result<String, ChainServiceError> {
117 let url = format!("{}{}", self.base_url, path);
118 info!("Fetching response text from {}", url);
119 let (response, _) = self.get_with_retry(&url, self.client.as_ref()).await?;
120 Ok(response)
121 }
122
123 async fn get_with_retry(
124 &self,
125 url: &str,
126 client: &dyn CommonRestClient,
127 ) -> Result<(String, u16), ChainServiceError> {
128 let mut delay = BASE_BACKOFF_MILLIS;
129 let mut attempts = 0;
130
131 loop {
132 let mut headers: Option<HashMap<String, String>> = None;
133 if let Some(basic_auth) = &self.basic_auth {
134 let auth_string = format!("{}:{}", basic_auth.username, basic_auth.password);
135 let encoded_auth = general_purpose::STANDARD.encode(auth_string.as_bytes());
136
137 headers = Some(
138 vec![("Authorization".to_string(), format!("Basic {encoded_auth}"))]
139 .into_iter()
140 .collect(),
141 );
142 }
143
144 let RestResponse { body, status } =
145 client.get_request(url.to_string(), headers).await?;
146 match status {
147 status if attempts < self.max_retries && is_status_retryable(status) => {
148 tokio::time::sleep(delay).await;
149 attempts = attempts.saturating_add(1);
150 delay = delay.saturating_mul(2);
151 }
152 _ => {
153 if !(200..300).contains(&status) {
154 return Err(ServiceConnectivityError::Status { status, body }.into());
155 }
156 return Ok((body, status));
157 }
158 }
159 }
160 }
161
162 async fn post(&self, url: &str, body: Option<String>) -> Result<String, ChainServiceError> {
163 let mut headers: HashMap<String, String> = HashMap::new();
164 headers.insert("Content-Type".to_string(), "text/plain".to_string());
165 if let Some(basic_auth) = &self.basic_auth {
166 let auth_string = format!("{}:{}", basic_auth.username, basic_auth.password);
167 let encoded_auth = general_purpose::STANDARD.encode(auth_string.as_bytes());
168 headers.insert("Authorization".to_string(), format!("Basic {encoded_auth}"));
169 }
170 info!(
171 "Posting to {} with body {} and headers {:?}",
172 url,
173 body.clone().unwrap_or_default(),
174 headers
175 );
176 let RestResponse { body, status } = self
177 .client
178 .post_request(url.to_string(), Some(headers), body)
179 .await?;
180 if !(200..300).contains(&status) {
181 return Err(ServiceConnectivityError::Status { status, body }.into());
182 }
183
184 Ok(body)
185 }
186
187 async fn recommended_fees_esplora(&self) -> Result<RecommendedFees, ChainServiceError> {
188 let fee_map = self
189 .get_response_json::<HashMap<u16, f64>>("/fee-estimates")
190 .await?;
191 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
192 let get_fees = |block: &u16| fee_map.get(block).map_or(0, |fee| fee.ceil() as u64);
193
194 Ok(RecommendedFees {
195 fastest_fee: get_fees(&1),
196 half_hour_fee: get_fees(&3),
197 hour_fee: get_fees(&6),
198 economy_fee: get_fees(&25),
199 minimum_fee: get_fees(&1008),
200 })
201 }
202
203 async fn recommended_fees_mempool_space(&self) -> Result<RecommendedFees, ChainServiceError> {
204 let response = self
205 .get_response_json::<MempoolSpaceRecommendedFeesResponse>("/v1/fees/recommended")
206 .await?;
207 Ok(response.into())
208 }
209}
210
211#[macros::async_trait]
212impl BitcoinChainService for RestClientChainService {
213 async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError> {
214 let address = address
215 .parse::<Address<NetworkUnchecked>>()?
216 .require_network(self.network.into())?;
217
218 let utxos = self
219 .get_response_json::<Vec<Utxo>>(format!("/address/{address}/utxo").as_str())
220 .await?;
221
222 Ok(utxos)
223 }
224
225 async fn get_transaction_status(
226 &self,
227 txid: String,
228 ) -> Result<super::TxStatus, ChainServiceError> {
229 let tx_info = self
230 .get_response_json::<TxInfo>(format!("/tx/{txid}").as_str())
231 .await?;
232 Ok(tx_info.status)
233 }
234
235 async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError> {
236 let tx = self
237 .get_response_text(format!("/tx/{txid}/hex").as_str())
238 .await?;
239 Ok(tx)
240 }
241
242 async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError> {
243 let url = format!("{}{}", self.base_url, "/tx");
244 self.post(&url, Some(tx)).await?;
245 Ok(())
246 }
247
248 async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError> {
249 match self.api_type {
250 ChainApiType::Esplora => self.recommended_fees_esplora().await,
251 ChainApiType::MempoolSpace => self.recommended_fees_mempool_space().await,
252 }
253 }
254}
255
256fn is_status_retryable(status: u16) -> bool {
257 RETRYABLE_ERROR_CODES.contains(&status)
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::Network;
264
265 use macros::async_test_all;
266
267 #[cfg(feature = "browser-tests")]
268 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
269
270 #[cfg(test)]
271 use breez_sdk_common::test_utils::mock_rest_client::{MockResponse, MockRestClient};
272
273 #[async_test_all]
274 async fn test_get_address_utxos() {
275 let mock_response = r#"[
277 {
278 "txid": "277bbdc3557f163810feea810bf390ed90724ec75de779ab181b865292bb1dc1",
279 "vout": 3,
280 "status": {
281 "confirmed": true,
282 "block_height": 725850,
283 "block_hash": "00000000000000000002d5aace1354d3f5420fcabf4e931f1c4c7ae9c0b405f8",
284 "block_time": 1646382740
285 },
286 "value": 24201
287 },
288 {
289 "txid": "3a3774433c15d8c1791806d25043335c2a53e5c0ed19517defa4dba9d0b2019f",
290 "vout": 0,
291 "status": {
292 "confirmed": true,
293 "block_height": 840719,
294 "block_hash": "0000000000000000000170deaa4ccf2de2f1c94346dfef40318d0a7c5178ffd3",
295 "block_time": 1713994081
296 },
297 "value": 30236
298 },
299 {
300 "txid": "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd",
301 "vout": 74,
302 "status": {
303 "confirmed": true,
304 "block_height": 726892,
305 "block_hash": "0000000000000000000841798eb13e9230c11f508121e6e1ba25fff3ad3bc448",
306 "block_time": 1647033214
307 },
308 "value": 5155
309 },
310 {
311 "txid": "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de",
312 "vout": 0,
313 "status": {
314 "confirmed": true,
315 "block_height": 857808,
316 "block_hash": "0000000000000000000286598ae217ea4e5b3c63359f3fe105106556182cb926",
317 "block_time": 1724272387
318 },
319 "value": 6127
320 },
321 {
322 "txid": "4654a83d953c68ba2c50473a80921bb4e1f01d428b18c65ff0128920865cc314",
323 "vout": 126,
324 "status": {
325 "confirmed": true,
326 "block_height": 748177,
327 "block_hash": "00000000000000000004a65956b7e99b3fcdfb1c01a9dfe5d6d43618427116be",
328 "block_time": 1659763398
329 },
330 "value": 22190
331 }
332 ]"#;
333
334 let mock = MockRestClient::new();
335 mock.add_response(MockResponse::new(200, mock_response.to_string()));
336
337 let service = RestClientChainService::new(
339 "http://localhost:8080".to_string(),
340 Network::Mainnet,
341 3,
342 Box::new(mock),
343 None,
344 ChainApiType::Esplora,
345 );
346
347 let mut result = service
349 .get_address_utxos("1wiz18xYmhRX6xStj2b9t1rwWX4GKUgpv".to_string())
350 .await
351 .unwrap();
352
353 result.sort_by(|a, b| a.value.cmp(&b.value));
355
356 assert_eq!(result.len(), 5);
358
359 assert_eq!(result[0].value, 5155); assert_eq!(
362 result[0].txid,
363 "5f2712d4ab1c9aa09c82c28e881724dc3c8c85cbbe71692e593f3911296d40fd"
364 );
365 assert_eq!(result[0].vout, 74);
366 assert!(result[0].status.confirmed);
367 assert_eq!(result[0].status.block_height, Some(726_892));
368
369 assert_eq!(result[1].value, 6127);
370 assert_eq!(
371 result[1].txid,
372 "7cb4410874b99055fda468dbca45b20ed910909641b46d9fb86869d560c462de"
373 );
374
375 assert_eq!(result[2].value, 22190);
376 assert_eq!(result[3].value, 24201);
377 assert_eq!(result[4].value, 30236); for utxo in &result {
381 assert!(utxo.status.confirmed);
382 assert!(utxo.status.block_height.is_some());
383 assert!(utxo.status.block_time.is_some());
384 }
385 }
386}