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