1use crate::{
2 error::{
3 BlocktankError, Result, ERR_INIT_HTTP_CLIENT, ERR_INVALID_REQUEST_FORMAT,
4 ERR_INVALID_REQUEST_PARAMS, ERR_INVOICE_SAT_ZERO, ERR_LSP_BALANCE_ZERO, ERR_NODE_ID_EMPTY,
5 ERR_ORDER_ID_CONNECTION_EMPTY,
6 },
7 types::blocktank::*,
8};
9use reqwest::{Client, ClientBuilder, Response, StatusCode};
10use serde_json::{json, Value};
11use std::time::Duration;
12use url::Url;
13
14const DEFAULT_BASE_URL: &str = "https://api1.blocktank.to/api";
15const DEFAULT_NOTIFICATION_URL: &str = "https://api.stag0.blocktank.to/notifications/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 { base_url, client })
32 }
33
34 fn normalize_url(url_str: &str) -> Result<Url> {
36 let fixed = if !url_str.ends_with('/') {
37 format!("{}/", url_str)
38 } else {
39 url_str.to_string()
40 };
41
42 Url::parse(&fixed).map_err(|e| BlocktankError::InitializationError {
43 message: format!("Invalid URL: {}", e),
44 })
45 }
46
47 fn create_http_client() -> Result<Client> {
49 let builder = ClientBuilder::new().timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
50
51 #[cfg(feature = "rustls-tls")]
52 let builder = builder.use_rustls_tls(); builder
55 .build()
56 .map_err(|e| BlocktankError::InitializationError {
57 message: format!("{}: {}", ERR_INIT_HTTP_CLIENT, e),
58 })
59 }
60
61 fn build_url(&self, path: &str, custom_url: Option<&str>) -> Result<Url> {
63 if custom_url.is_some() {
64 let base = custom_url.unwrap_or(DEFAULT_NOTIFICATION_URL);
65 let base_url = Self::normalize_url(base)?;
66 base_url
67 .join(path)
68 .map_err(|e| BlocktankError::Client(format!("Failed to build URL: {}", e)))
69 } else {
70 self.base_url
71 .join(path)
72 .map_err(|e| BlocktankError::Client(format!("Failed to build URL: {}", e)))
73 }
74 }
75
76 fn create_payload<T>(&self, base_payload: Value, options: Option<T>) -> Result<Value>
78 where
79 T: serde::Serialize,
80 {
81 let mut payload = base_payload;
82 if let Some(opts) = options {
83 let options_json = serde_json::to_value(opts).map_err(|e| {
84 BlocktankError::Client(format!("Failed to serialize options: {}", e))
85 })?;
86 if let Value::Object(options_map) = options_json {
87 if let Value::Object(ref mut payload_map) = payload {
88 payload_map.extend(
90 options_map
91 .into_iter()
92 .filter(|(_, v)| !v.is_null() && !Self::is_default_value(v)),
93 );
94 }
95 }
96 }
97 Ok(payload)
98 }
99
100 fn is_default_value(value: &Value) -> bool {
102 match value {
103 Value::Null => true,
104 Value::String(s) => s.is_empty(),
107 Value::Array(a) => a.is_empty(),
108 Value::Object(o) => o.is_empty(),
109 _ => false,
110 }
111 }
112
113 async fn handle_response<T>(&self, response: Response) -> Result<T>
115 where
116 T: serde::de::DeserializeOwned,
117 {
118 match response.status() {
119 status if status.is_success() => Ok(response.json().await?),
120 StatusCode::BAD_REQUEST => match response.json::<ApiValidationError>().await {
121 Ok(error) => {
122 if let Some(issue) = error.errors.issues.first() {
123 Err(BlocktankError::InvalidParameter {
124 message: issue.message.clone(),
125 })
126 } else {
127 Err(BlocktankError::Client(
128 ERR_INVALID_REQUEST_PARAMS.to_string(),
129 ))
130 }
131 }
132 Err(_) => Err(BlocktankError::Client(
133 ERR_INVALID_REQUEST_FORMAT.to_string(),
134 )),
135 },
136 status => Err(BlocktankError::Client(format!(
137 "Request failed with status: {}",
138 status
139 ))),
140 }
141 }
142
143 async fn wrap_error_handler<F, T>(&self, message: &str, f: F) -> Result<T>
145 where
146 F: std::future::Future<Output = Result<T>>,
147 {
148 match f.await {
149 Ok(result) => Ok(result),
150 Err(e) => Err(BlocktankError::BlocktankClient {
151 message: format!("{}: {}", message, e),
152 data: json!(e.to_string()),
153 }),
154 }
155 }
156
157 pub async fn get_info(&self) -> Result<IBtInfo> {
163 self.wrap_error_handler("Failed to get info", async {
164 let url = self.build_url("info", None)?;
165 let response = self.client.get(url).send().await?;
166 self.handle_response(response).await
167 })
168 .await
169 }
170
171 pub async fn estimate_order_fee(
173 &self,
174 lsp_balance_sat: u64,
175 channel_expiry_weeks: u32,
176 options: Option<CreateOrderOptions>,
177 ) -> Result<IBtEstimateFeeResponse> {
178 self.wrap_error_handler("Failed to estimate channel order fee", async {
179 let base_payload = json!({
180 "lspBalanceSat": lsp_balance_sat,
181 "channelExpiryWeeks": channel_expiry_weeks,
182 });
183
184 let payload = self.create_payload(base_payload, options)?;
185 let url = self.build_url("channels/estimate-fee", None)?;
186
187 let response = self.client.post(url).json(&payload).send().await?;
188
189 self.handle_response(response).await
190 })
191 .await
192 }
193
194 pub async fn estimate_order_fee_full(
197 &self,
198 lsp_balance_sat: u64,
199 channel_expiry_weeks: u32,
200 options: Option<CreateOrderOptions>,
201 ) -> Result<IBtEstimateFeeResponse2> {
202 self.wrap_error_handler("Failed to estimate channel order fee", async {
203 let base_payload = json!({
204 "lspBalanceSat": lsp_balance_sat,
205 "channelExpiryWeeks": channel_expiry_weeks,
206 });
207
208 let payload = self.create_payload(base_payload, options)?;
209 let url = self.build_url("channels/estimate-fee-full", None)?;
210
211 let response = self.client.post(url).json(&payload).send().await?;
212
213 self.handle_response(response).await
214 })
215 .await
216 }
217
218 pub async fn create_order(
220 &self,
221 lsp_balance_sat: u64,
222 channel_expiry_weeks: u32,
223 options: Option<CreateOrderOptions>,
224 ) -> Result<IBtOrder> {
225 if lsp_balance_sat == 0 {
226 return Err(BlocktankError::InvalidParameter {
227 message: ERR_LSP_BALANCE_ZERO.to_string(),
228 });
229 }
230
231 let base_payload = json!({
232 "lspBalanceSat": lsp_balance_sat,
233 "channelExpiryWeeks": channel_expiry_weeks,
234 "clientBalanceSat": 0,
235 });
236
237 let payload = self.create_payload(base_payload, options)?;
238 let url = self.build_url("channels", None)?;
239
240 let response = self
241 .client
242 .post(url)
243 .header("Content-Type", "application/json")
244 .json(&payload)
245 .send()
246 .await?;
247
248 self.handle_response(response).await
249 }
250
251 pub async fn get_order(&self, order_id: &str) -> Result<IBtOrder> {
253 self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
254 let url = self.build_url(&format!("channels/{}", order_id), None)?;
255 let response = self.client.get(url).send().await?;
256 self.handle_response(response).await
257 })
258 .await
259 }
260
261 pub async fn get_orders(&self, order_ids: &[String]) -> Result<Vec<IBtOrder>> {
263 self.wrap_error_handler(&format!("Failed to fetch orders {:?}", order_ids), async {
264 let url = self.build_url("channels", None)?;
265
266 let query_params: Vec<(&str, &str)> =
267 order_ids.iter().map(|id| ("ids[]", id.as_str())).collect();
268
269 let response = self.client.get(url).query(&query_params).send().await?;
270
271 self.handle_response(response).await
272 })
273 .await
274 }
275
276 pub async fn open_channel(
278 &self,
279 order_id: &str,
280 connection_string_or_pubkey: &str,
281 ) -> Result<IBtOrder> {
282 if order_id.is_empty() || connection_string_or_pubkey.is_empty() {
283 return Err(BlocktankError::InvalidParameter {
284 message: ERR_ORDER_ID_CONNECTION_EMPTY.to_string(),
285 });
286 }
287
288 self.wrap_error_handler(
289 &format!("Failed to open the channel for order {}", order_id),
290 async {
291 let payload = json!({
292 "connectionStringOrPubkey": connection_string_or_pubkey,
293 });
294
295 let url = self.build_url(&format!("channels/{}/open", order_id), None)?;
296 let response = self.client.post(url).json(&payload).send().await?;
297
298 self.handle_response(response).await
299 },
300 )
301 .await
302 }
303
304 pub async fn get_min_zero_conf_tx_fee(&self, order_id: &str) -> Result<IBt0ConfMinTxFeeWindow> {
306 self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
307 let url = self.build_url(&format!("channels/{}/min-0conf-tx-fee", order_id), None)?;
308 let response = self.client.get(url).send().await?;
309 self.handle_response(response).await
310 })
311 .await
312 }
313
314 pub async fn create_cjit_entry(
316 &self,
317 channel_size_sat: u64,
318 invoice_sat: u64,
319 invoice_description: &str,
320 node_id: &str,
321 channel_expiry_weeks: u32,
322 options: Option<CreateCjitOptions>,
323 ) -> Result<ICJitEntry> {
324 if channel_size_sat == 0 {
325 return Err(BlocktankError::InvalidParameter {
326 message: ERR_LSP_BALANCE_ZERO.to_string(),
327 });
328 }
329
330 if invoice_sat == 0 {
331 return Err(BlocktankError::InvalidParameter {
332 message: ERR_INVOICE_SAT_ZERO.to_string(),
333 });
334 }
335
336 if node_id.is_empty() {
337 return Err(BlocktankError::InvalidParameter {
338 message: ERR_NODE_ID_EMPTY.to_string(),
339 });
340 }
341
342 let base_payload = json!({
343 "channelSizeSat": channel_size_sat,
344 "invoiceSat": invoice_sat,
345 "invoiceDescription": invoice_description,
346 "channelExpiryWeeks": channel_expiry_weeks,
347 "nodeId": node_id,
348 });
349
350 let payload = self.create_payload(base_payload, options)?;
351 let url = self.build_url("cjit", None)?;
352
353 let response = self.client.post(url).json(&payload).send().await?;
354
355 self.handle_response(response).await
356 }
357
358 pub async fn get_cjit_entry(&self, entry_id: &str) -> Result<ICJitEntry> {
360 self.wrap_error_handler(&format!("Failed to fetch cjit entry {}", entry_id), async {
361 let url = self.build_url(&format!("cjit/{}", entry_id), None)?;
362 let response = self.client.get(url).send().await?;
363 self.handle_response(response).await
364 })
365 .await
366 }
367
368 pub async fn bitkit_log(&self, node_id: &str, message: &str) -> Result<()> {
370 self.wrap_error_handler(&format!("Failed to send log for {}", node_id), async {
371 let payload = json!({
372 "nodeId": node_id,
373 "message": message,
374 });
375
376 let url = self.build_url("bitkit/log", None)?;
377 let response = self.client.post(url).json(&payload).send().await?;
378
379 response.error_for_status()?;
380 Ok(())
381 })
382 .await
383 }
384
385 pub async fn regtest_mine(&self, count: Option<u32>) -> Result<()> {
391 let blocks_to_mine = count.unwrap_or(1);
392 self.wrap_error_handler(
393 &format!("Failed to mine {} blocks", blocks_to_mine),
394 async {
395 let payload = json!({ "count": blocks_to_mine });
396 let url = self.build_url("regtest/chain/mine", None)?;
397 let response = self.client.post(url).json(&payload).send().await?;
398
399 response.error_for_status()?;
400 Ok(())
401 },
402 )
403 .await
404 }
405
406 pub async fn regtest_deposit(&self, address: &str, amount_sat: Option<u64>) -> Result<String> {
409 self.wrap_error_handler(&format!("Failed to deposit to {}", address), async {
410 let mut payload = json!({
411 "address": address,
412 });
413
414 if let Some(amount_sat) = amount_sat {
415 payload
416 .as_object_mut()
417 .unwrap()
418 .insert("amountSat".to_string(), json!(amount_sat));
419 }
420
421 let url = self.build_url("regtest/chain/deposit", None)?;
422 let response = self.client.post(url).json(&payload).send().await?;
423
424 let result = response.error_for_status()?.text().await?;
425 Ok(result)
426 })
427 .await
428 }
429
430 pub async fn regtest_pay(&self, invoice: &str, amount_sat: Option<u64>) -> Result<String> {
433 self.wrap_error_handler("Failed to pay invoice", async {
434 let payload = json!({
435 "invoice": invoice,
436 "amountSat": amount_sat,
437 });
438
439 let url = self.build_url("regtest/channel/pay", None)?;
440 let response = self.client.post(url).json(&payload).send().await?;
441
442 let result = response.error_for_status()?.text().await?;
443 Ok(result)
444 })
445 .await
446 }
447
448 pub async fn regtest_get_payment(&self, payment_id: &str) -> Result<IBtBolt11Invoice> {
450 self.wrap_error_handler(&format!("Failed to get payment {}", payment_id), async {
451 let url = self.build_url(&format!("regtest/channel/pay/{}", payment_id), None)?;
452 let response = self.client.get(url).send().await?;
453 self.handle_response(response).await
454 })
455 .await
456 }
457
458 pub async fn regtest_close_channel(
461 &self,
462 funding_tx_id: &str,
463 vout: u32,
464 force_close_after_s: Option<u64>,
465 ) -> Result<String> {
466 let force_desc = if force_close_after_s.is_some() {
467 " force"
468 } else {
469 ""
470 };
471 self.wrap_error_handler(
472 &format!(
473 "Failed to{} close the channel {}:{}",
474 force_desc, funding_tx_id, vout
475 ),
476 async {
477 let mut payload = json!({
478 "fundingTxId": funding_tx_id,
479 "vout": vout,
480 });
481
482 if let Some(force_close_after_s) = force_close_after_s {
483 payload
484 .as_object_mut()
485 .unwrap()
486 .insert("forceCloseAfterS".to_string(), json!(force_close_after_s));
487 }
488
489 let url = self.build_url("regtest/channel/close", None)?;
490 let response = self.client.post(url).json(&payload).send().await?;
491
492 let result = response.error_for_status()?.text().await?;
493 Ok(result)
494 },
495 )
496 .await
497 }
498
499 pub async fn register_device(
501 &self,
502 device_token: &str,
503 public_key: &str,
504 features: &[String],
505 node_id: &str,
506 iso_timestamp: &str,
507 signature: &str,
508 custom_url: Option<&str>,
509 ) -> Result<String> {
510 self.wrap_error_handler("Failed to register device", async {
511 let custom_url = custom_url.or(Some(DEFAULT_NOTIFICATION_URL));
512 let url = self.build_url("device", custom_url)?;
513
514 let payload = json!({
515 "deviceToken": device_token,
516 "publicKey": public_key,
517 "features": features,
518 "nodeId": node_id,
519 "isoTimestamp": iso_timestamp,
520 "signature": signature
521 });
522
523 let response = self.client.post(url).json(&payload).send().await?;
524
525 let status = response.status();
526 if !status.is_success() {
527 let error_text = response.text().await?;
528 return Err(BlocktankError::Client(format!(
529 "Device registration failed. Status: {}. Response: {}",
530 status, error_text
531 )));
532 }
533
534 response.text().await.map_err(|e| e.into())
535 })
536 .await
537 }
538
539 pub async fn test_notification(
541 &self,
542 device_token: &str,
543 secret_message: &str,
544 notification_type: Option<&str>,
545 custom_url: Option<&str>,
546 ) -> Result<String> {
547 let notification_type = notification_type.unwrap_or("orderPaymentConfirmed");
548 let custom_url = custom_url.or(Some(DEFAULT_NOTIFICATION_URL));
549 self.wrap_error_handler("Failed to send test notification", async {
550 let url = self.build_url(
551 &format!("device/{}/test-notification", device_token),
552 custom_url,
553 )?;
554
555 let payload = json!({
556 "data": {
557 "source": "blocktank",
558 "type": notification_type,
559 "payload": {
560 "secretMessage": secret_message
561 }
562 }
563 });
564
565 let response = self.client.post(url).json(&payload).send().await?;
566
567 let status = response.status();
568 if !status.is_success() {
569 let error_text = response.text().await?;
570 return Err(BlocktankError::Client(format!(
571 "Test notification failed. Status: {}. Response: {}",
572 status, error_text
573 )));
574 }
575
576 response.text().await.map_err(|e| e.into())
577 })
578 .await
579 }
580}