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