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