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