1use std::time::Duration;
2use crate::{
3 error::{
4 Result, BlocktankError,
5 ERR_INIT_HTTP_CLIENT, ERR_LSP_BALANCE_ZERO, ERR_INVOICE_SAT_ZERO,
6 ERR_NODE_ID_EMPTY, ERR_ORDER_ID_CONNECTION_EMPTY, ERR_INVALID_REQUEST_PARAMS,
7 ERR_INVALID_REQUEST_FORMAT
8 },
9 types::blocktank::*
10};
11use reqwest::{Client, ClientBuilder, Response, StatusCode};
12use serde_json::{json, Value};
13use url::Url;
14
15const DEFAULT_BASE_URL: &str = "https://api1.blocktank.to/api";
16const DEFAULT_TIMEOUT_SECS: u64 = 30;
17
18#[derive(Clone, Debug)]
19pub struct BlocktankClient {
20 base_url: Url,
21 client: Client,
22}
23
24impl BlocktankClient {
25 pub fn new(base_url: Option<&str>) -> Result<Self> {
27 let base = base_url.unwrap_or(DEFAULT_BASE_URL);
28 let base_url = Self::normalize_url(base)?;
29 let client = Self::create_http_client()?;
30
31 Ok(Self {
32 base_url,
33 client,
34 })
35 }
36
37 fn normalize_url(url_str: &str) -> Result<Url> {
39 let fixed = if !url_str.ends_with('/') {
40 format!("{}/", url_str)
41 } else {
42 url_str.to_string()
43 };
44
45 Url::parse(&fixed).map_err(|e| BlocktankError::InitializationError {
46 message: format!("Invalid URL: {}", e),
47 })
48 }
49
50 fn create_http_client() -> Result<Client> {
52 let builder = ClientBuilder::new().timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
53
54 #[cfg(feature = "rustls-tls")]
55 let builder = builder.use_rustls_tls(); builder.build()
58 .map_err(|e| BlocktankError::InitializationError {
59 message: format!("{}: {}", ERR_INIT_HTTP_CLIENT, e),
60 })
61 }
62
63 fn build_url(&self, path: &str) -> Result<Url> {
65 self.base_url.join(path).map_err(|e| {
66 BlocktankError::Client(format!("Failed to build URL: {}", e))
67 })
68 }
69
70 fn create_payload<T>(&self, base_payload: Value, options: Option<T>) -> Result<Value>
72 where
73 T: serde::Serialize,
74 {
75 let mut payload = base_payload;
76 if let Some(opts) = options {
77 let options_json = serde_json::to_value(opts)
78 .map_err(|e| BlocktankError::Client(format!("Failed to serialize options: {}", e)))?;
79 if let Value::Object(options_map) = options_json {
80 if let Value::Object(ref mut payload_map) = payload {
81 payload_map.extend(
83 options_map
84 .into_iter()
85 .filter(|(_, v)| !v.is_null() && !Self::is_default_value(v))
86 );
87 }
88 }
89 }
90 Ok(payload)
91 }
92
93 fn is_default_value(value: &Value) -> bool {
95 match value {
96 Value::Bool(b) => !*b,
97 Value::Number(n) => n == &serde_json::Number::from(0),
98 Value::String(s) => s.is_empty(),
99 Value::Array(a) => a.is_empty(),
100 Value::Object(o) => o.is_empty(),
101 _ => false,
102 }
103 }
104
105 async fn handle_response<T>(&self, response: Response) -> Result<T>
107 where
108 T: serde::de::DeserializeOwned,
109 {
110 match response.status() {
111 status if status.is_success() => {
112 Ok(response.json().await?)
113 }
114 StatusCode::BAD_REQUEST => {
115 match response.json::<ApiValidationError>().await {
116 Ok(error) => {
117 if let Some(issue) = error.errors.issues.first() {
118 Err(BlocktankError::InvalidParameter {
119 message: issue.message.clone(),
120 })
121 } else {
122 Err(BlocktankError::Client(ERR_INVALID_REQUEST_PARAMS.to_string()))
123 }
124 }
125 Err(_) => Err(BlocktankError::Client(ERR_INVALID_REQUEST_FORMAT.to_string()))
126 }
127 }
128 status => {
129 Err(BlocktankError::Client(format!(
130 "Request failed with status: {}",
131 status
132 )))
133 }
134 }
135 }
136
137 async fn wrap_error_handler<F, T>(&self, message: &str, f: F) -> Result<T>
139 where
140 F: std::future::Future<Output = Result<T>>,
141 {
142 match f.await {
143 Ok(result) => Ok(result),
144 Err(e) => {
145 Err(BlocktankError::BlocktankClient {
146 message: format!("{}: {}", message, e),
147 data: json!(e.to_string()),
148 })
149 }
150 }
151 }
152
153 pub async fn get_info(&self) -> Result<IBtInfo> {
159 self.wrap_error_handler("Failed to get info", async {
160 let url = self.build_url("info")?;
161 let response = self.client.get(url).send().await?;
162 self.handle_response(response).await
163 })
164 .await
165 }
166
167 pub async fn estimate_order_fee(
169 &self,
170 lsp_balance_sat: u64,
171 channel_expiry_weeks: u32,
172 options: Option<CreateOrderOptions>,
173 ) -> Result<IBtEstimateFeeResponse> {
174 self.wrap_error_handler("Failed to estimate channel order fee", async {
175 let base_payload = json!({
176 "lspBalanceSat": lsp_balance_sat,
177 "channelExpiryWeeks": channel_expiry_weeks,
178 });
179
180 let payload = self.create_payload(base_payload, options)?;
181 let url = self.build_url("channels/estimate-fee")?;
182
183 let response = self.client.post(url)
184 .json(&payload)
185 .send()
186 .await?;
187
188 self.handle_response(response).await
189 })
190 .await
191 }
192
193 pub async fn estimate_order_fee_full(
196 &self,
197 lsp_balance_sat: u64,
198 channel_expiry_weeks: u32,
199 options: Option<CreateOrderOptions>,
200 ) -> Result<IBtEstimateFeeResponse2> {
201 self.wrap_error_handler("Failed to estimate channel order fee", async {
202 let base_payload = json!({
203 "lspBalanceSat": lsp_balance_sat,
204 "channelExpiryWeeks": channel_expiry_weeks,
205 });
206
207 let payload = self.create_payload(base_payload, options)?;
208 let url = self.build_url("channels/estimate-fee-full")?;
209
210 let response = self.client.post(url)
211 .json(&payload)
212 .send()
213 .await?;
214
215 self.handle_response(response).await
216 })
217 .await
218 }
219
220 pub async fn create_order(
222 &self,
223 lsp_balance_sat: u64,
224 channel_expiry_weeks: u32,
225 options: Option<CreateOrderOptions>,
226 ) -> Result<IBtOrder> {
227 if lsp_balance_sat == 0 {
228 return Err(BlocktankError::InvalidParameter {
229 message: ERR_LSP_BALANCE_ZERO.to_string(),
230 });
231 }
232
233 let base_payload = json!({
234 "lspBalanceSat": lsp_balance_sat,
235 "channelExpiryWeeks": channel_expiry_weeks,
236 "clientBalanceSat": 0,
237 });
238
239 let payload = self.create_payload(base_payload, options)?;
240 let url = self.build_url("channels")?;
241
242 let response = self.client
243 .post(url)
244 .header("Content-Type", "application/json")
245 .json(&payload)
246 .send()
247 .await?;
248
249 self.handle_response(response).await
250 }
251
252 pub async fn get_order(&self, order_id: &str) -> Result<IBtOrder> {
254 self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
255 let url = self.build_url(&format!("channels/{}", order_id))?;
256 let response = self.client.get(url).send().await?;
257 self.handle_response(response).await
258 })
259 .await
260 }
261
262 pub async fn get_orders(&self, order_ids: &[String]) -> Result<Vec<IBtOrder>> {
264 self.wrap_error_handler(&format!("Failed to fetch orders {:?}", order_ids), async {
265 let url = self.build_url("channels")?;
266
267 let query_params: Vec<(&str, &str)> = order_ids
268 .iter()
269 .map(|id| ("ids[]", id.as_str()))
270 .collect();
271
272 let response = self.client.get(url)
273 .query(&query_params)
274 .send()
275 .await?;
276
277 self.handle_response(response).await
278 })
279 .await
280 }
281
282 pub async fn open_channel(
284 &self,
285 order_id: &str,
286 connection_string_or_pubkey: &str,
287 ) -> Result<IBtOrder> {
288 if order_id.is_empty() || connection_string_or_pubkey.is_empty() {
289 return Err(BlocktankError::InvalidParameter {
290 message: ERR_ORDER_ID_CONNECTION_EMPTY.to_string()
291 });
292 }
293
294 self.wrap_error_handler(&format!("Failed to open the channel for order {}", order_id), async {
295 let payload = json!({
296 "connectionStringOrPubkey": connection_string_or_pubkey,
297 });
298
299 let url = self.build_url(&format!("channels/{}/open", order_id))?;
300 let response = self.client.post(url)
301 .json(&payload)
302 .send()
303 .await?;
304
305 self.handle_response(response).await
306 })
307 .await
308 }
309
310 pub async fn get_min_zero_conf_tx_fee(&self, order_id: &str) -> Result<IBt0ConfMinTxFeeWindow> {
312 self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
313 let url = self.build_url(&format!("channels/{}/min-0conf-tx-fee", order_id))?;
314 let response = self.client.get(url).send().await?;
315 self.handle_response(response).await
316 })
317 .await
318 }
319
320 pub async fn create_cjit_entry(
322 &self,
323 channel_size_sat: u64,
324 invoice_sat: u64,
325 invoice_description: &str,
326 node_id: &str,
327 channel_expiry_weeks: u32,
328 options: Option<CreateCjitOptions>,
329 ) -> Result<ICJitEntry> {
330 if channel_size_sat == 0 {
331 return Err(BlocktankError::InvalidParameter {
332 message: ERR_LSP_BALANCE_ZERO.to_string(),
333 });
334 }
335
336 if invoice_sat == 0 {
337 return Err(BlocktankError::InvalidParameter {
338 message: ERR_INVOICE_SAT_ZERO.to_string(),
339 });
340 }
341
342 if node_id.is_empty() {
343 return Err(BlocktankError::InvalidParameter {
344 message: ERR_NODE_ID_EMPTY.to_string(),
345 });
346 }
347
348 let base_payload = json!({
349 "channelSizeSat": channel_size_sat,
350 "invoiceSat": invoice_sat,
351 "invoiceDescription": invoice_description,
352 "channelExpiryWeeks": channel_expiry_weeks,
353 "nodeId": node_id,
354 });
355
356 let payload = self.create_payload(base_payload, options)?;
357 let url = self.build_url("cjit")?;
358
359 let response = self.client
360 .post(url)
361 .json(&payload)
362 .send()
363 .await?;
364
365 self.handle_response(response).await
366 }
367
368 pub async fn get_cjit_entry(&self, entry_id: &str) -> Result<ICJitEntry> {
370 self.wrap_error_handler(&format!("Failed to fetch cjit entry {}", entry_id), async {
371 let url = self.build_url(&format!("cjit/{}", entry_id))?;
372 let response = self.client.get(url).send().await?;
373 self.handle_response(response).await
374 })
375 .await
376 }
377
378 pub async fn bitkit_log(&self, node_id: &str, message: &str) -> Result<()> {
380 self.wrap_error_handler(&format!("Failed to send log for {}", node_id), async {
381 let payload = json!({
382 "nodeId": node_id,
383 "message": message,
384 });
385
386 let url = self.build_url("bitkit/log")?;
387 let response = self.client.post(url)
388 .json(&payload)
389 .send()
390 .await?;
391
392 response.error_for_status()?;
393 Ok(())
394 })
395 .await
396 }
397
398 pub async fn regtest_mine(&self, count: Option<u32>) -> Result<()> {
404 let blocks_to_mine = count.unwrap_or(1);
405 self.wrap_error_handler(&format!("Failed to mine {} blocks", blocks_to_mine), async {
406 let payload = json!({ "count": blocks_to_mine });
407 let url = self.build_url("regtest/chain/mine")?;
408 let response = self.client.post(url)
409 .json(&payload)
410 .send()
411 .await?;
412
413 response.error_for_status()?;
414 Ok(())
415 })
416 .await
417 }
418
419 pub async fn regtest_deposit(
422 &self,
423 address: &str,
424 amount_sat: Option<u64>,
425 ) -> Result<String> {
426 self.wrap_error_handler(&format!("Failed to deposit to {}", address), async {
427 let mut payload = json!({
428 "address": address,
429 });
430
431 if let Some(amount_sat) = amount_sat {
432 payload.as_object_mut().unwrap().insert("amountSat".to_string(), json!(amount_sat));
433 }
434
435 let url = self.build_url("regtest/chain/deposit")?;
436 let response = self.client.post(url)
437 .json(&payload)
438 .send()
439 .await?;
440
441 let result = response.error_for_status()?.text().await?;
442 Ok(result)
443 })
444 .await
445 }
446
447 pub async fn regtest_pay(
450 &self,
451 invoice: &str,
452 amount_sat: Option<u64>,
453 ) -> Result<String> {
454 self.wrap_error_handler("Failed to pay invoice", async {
455 let payload = json!({
456 "invoice": invoice,
457 "amountSat": amount_sat,
458 });
459
460 let url = self.build_url("regtest/channel/pay")?;
461 let response = self.client.post(url)
462 .json(&payload)
463 .send()
464 .await?;
465
466 let result = response.error_for_status()?.text().await?;
467 Ok(result)
468 })
469 .await
470 }
471
472 pub async fn regtest_get_payment(&self, payment_id: &str) -> Result<IBtBolt11Invoice> {
474 self.wrap_error_handler(&format!("Failed to get payment {}", payment_id), async {
475 let url = self.build_url(&format!("regtest/channel/pay/{}", payment_id))?;
476 let response = self.client.get(url).send().await?;
477 self.handle_response(response).await
478 })
479 .await
480 }
481
482 pub async fn regtest_close_channel(
485 &self,
486 funding_tx_id: &str,
487 vout: u32,
488 force_close_after_s: Option<u64>,
489 ) -> Result<String> {
490 let force_desc = if force_close_after_s.is_some() { " force" } else { "" };
491 self.wrap_error_handler(&format!("Failed to{} close the channel {}:{}",
492 force_desc, funding_tx_id, vout), async {
493 let mut payload = json!({
494 "fundingTxId": funding_tx_id,
495 "vout": vout,
496 });
497
498 if let Some(force_close_after_s) = force_close_after_s {
499 payload.as_object_mut().unwrap().insert(
500 "forceCloseAfterS".to_string(),
501 json!(force_close_after_s),
502 );
503 }
504
505 let url = self.build_url("regtest/channel/close")?;
506 let response = self.client.post(url)
507 .json(&payload)
508 .send()
509 .await?;
510
511 let result = response.error_for_status()?.text().await?;
512 Ok(result)
513 })
514 .await
515 }
516}