rust_blocktank_client/
client.rs

1use crate::{
2    error::{
3        BlocktankError, Result, ERR_INIT_HTTP_CLIENT,
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    /// Creates a new BlocktankClient instance with the given base URL or default if not provided
26    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    /// Normalizes a URL string to ensure it ends with a slash
35    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    /// Creates an HTTP client with appropriate configuration
48    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(); // Explicitly use rustls
53
54        builder
55            .build()
56            .map_err(|e| BlocktankError::InitializationError {
57                message: format!("{}: {}", ERR_INIT_HTTP_CLIENT, e),
58            })
59    }
60
61    /// Builds a URL by joining the base URL with the given path
62    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    /// Creates a JSON payload from a base payload and options, filtering out null or default values
77    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                    // Filter out null or default values as before.
89                    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    /// Checks if a JSON value represents a default/empty value
101    fn is_default_value(value: &Value) -> bool {
102        match value {
103            Value::Null => true,
104            //Value::Bool(b) => !*b,
105            //Value::Number(n) => n == &serde_json::Number::from(0),
106            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    /// Handles API responses with error processing
114    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 => {
121                // First, try to get the raw error text
122                let error_text = response.text().await?;
123                
124                // Try to parse as ApiValidationError first
125                if let Ok(error) = serde_json::from_str::<ApiValidationError>(&error_text) {
126                    if let Some(issue) = error.errors.issues.first() {
127                        return Err(BlocktankError::InvalidParameter {
128                            message: issue.message.clone(),
129                        });
130                    } else {
131                        return Err(BlocktankError::Client(
132                            ERR_INVALID_REQUEST_PARAMS.to_string(),
133                        ));
134                    }
135                }
136                
137                // Try to parse as simple error message
138                if let Ok(simple_error) = serde_json::from_str::<serde_json::Value>(&error_text) {
139                    if let Some(message) = simple_error.get("message").and_then(|m| m.as_str()) {
140                        return Err(BlocktankError::Client(format!("API error: {}", message)));
141                    }
142                }
143                
144                // If we can't parse as any known format, return the actual error text
145                Err(BlocktankError::Client(format!(
146                    "Client error: Invalid request format {}",
147                    error_text
148                )))
149            },
150            status => {
151                // Try to get error details from response body
152                let error_details = response.text().await.unwrap_or_else(|_| String::from("No error details available"));
153                Err(BlocktankError::Client(format!(
154                    "Request failed with status: {} - {}",
155                    status, error_details
156                )))
157            },
158        }
159    }
160
161    /// Internal error wrapper to match TypeScript behavior
162    async fn wrap_error_handler<F, T>(&self, message: &str, f: F) -> Result<T>
163    where
164        F: std::future::Future<Output = Result<T>>,
165    {
166        match f.await {
167            Ok(result) => Ok(result),
168            Err(e) => Err(BlocktankError::BlocktankClient {
169                message: format!("{}: {}", message, e),
170                data: json!(e.to_string()),
171            }),
172        }
173    }
174
175    //
176    // Public API Methods
177    //
178
179    /// Get general service information.
180    pub async fn get_info(&self) -> Result<IBtInfo> {
181        self.wrap_error_handler("Failed to get info", async {
182            let url = self.build_url("info", None)?;
183            let response = self.client.get(url).send().await?;
184            self.handle_response(response).await
185        })
186        .await
187    }
188
189    /// Estimates the fee to create a channel order without actually creating an order.
190    pub async fn estimate_order_fee(
191        &self,
192        lsp_balance_sat: u64,
193        channel_expiry_weeks: u32,
194        options: Option<CreateOrderOptions>,
195    ) -> Result<IBtEstimateFeeResponse> {
196        self.wrap_error_handler("Failed to estimate channel order fee", async {
197            let base_payload = json!({
198                "lspBalanceSat": lsp_balance_sat,
199                "channelExpiryWeeks": channel_expiry_weeks,
200            });
201
202            let payload = self.create_payload(base_payload, options)?;
203            let url = self.build_url("channels/estimate-fee", None)?;
204
205            let response = self.client.post(url).json(&payload).send().await?;
206
207            self.handle_response(response).await
208        })
209        .await
210    }
211
212    /// Estimates the fee to create a channel order without actually creating an order.
213    /// Includes network and service fee.
214    pub async fn estimate_order_fee_full(
215        &self,
216        lsp_balance_sat: u64,
217        channel_expiry_weeks: u32,
218        options: Option<CreateOrderOptions>,
219    ) -> Result<IBtEstimateFeeResponse2> {
220        self.wrap_error_handler("Failed to estimate channel order fee", async {
221            let base_payload = json!({
222                "lspBalanceSat": lsp_balance_sat,
223                "channelExpiryWeeks": channel_expiry_weeks,
224            });
225
226            let payload = self.create_payload(base_payload, options)?;
227            let url = self.build_url("channels/estimate-fee-full", None)?;
228
229            let response = self.client.post(url).json(&payload).send().await?;
230
231            self.handle_response(response).await
232        })
233        .await
234    }
235
236    /// Creates a new channel order
237    pub async fn create_order(
238        &self,
239        lsp_balance_sat: u64,
240        channel_expiry_weeks: u32,
241        options: Option<CreateOrderOptions>,
242    ) -> Result<IBtOrder> {
243        if lsp_balance_sat == 0 {
244            return Err(BlocktankError::InvalidParameter {
245                message: ERR_LSP_BALANCE_ZERO.to_string(),
246            });
247        }
248
249        let base_payload = json!({
250            "lspBalanceSat": lsp_balance_sat,
251            "channelExpiryWeeks": channel_expiry_weeks,
252            "clientBalanceSat": 0,
253        });
254
255        let payload = self.create_payload(base_payload, options)?;
256        let url = self.build_url("channels", None)?;
257
258        let response = self
259            .client
260            .post(url)
261            .header("Content-Type", "application/json")
262            .json(&payload)
263            .send()
264            .await?;
265
266        self.handle_response(response).await
267    }
268
269    /// Get order by order id. Returns an error if it doesn't find the order.
270    pub async fn get_order(&self, order_id: &str) -> Result<IBtOrder> {
271        self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
272            let url = self.build_url(&format!("channels/{}", order_id), None)?;
273            let response = self.client.get(url).send().await?;
274            self.handle_response(response).await
275        })
276        .await
277    }
278
279    /// Get multiple orders by order ids.
280    pub async fn get_orders(&self, order_ids: &[String]) -> Result<Vec<IBtOrder>> {
281        self.wrap_error_handler(&format!("Failed to fetch orders {:?}", order_ids), async {
282            let url = self.build_url("channels", None)?;
283
284            let query_params: Vec<(&str, &str)> =
285                order_ids.iter().map(|id| ("ids[]", id.as_str())).collect();
286
287            let response = self.client.get(url).query(&query_params).send().await?;
288
289            self.handle_response(response).await
290        })
291        .await
292    }
293
294    /// Open channel to a specific node.
295    pub async fn open_channel(
296        &self,
297        order_id: &str,
298        connection_string_or_pubkey: &str,
299    ) -> Result<IBtOrder> {
300        if order_id.is_empty() || connection_string_or_pubkey.is_empty() {
301            return Err(BlocktankError::InvalidParameter {
302                message: ERR_ORDER_ID_CONNECTION_EMPTY.to_string(),
303            });
304        }
305
306        self.wrap_error_handler(
307            &format!("Failed to open the channel for order {}", order_id),
308            async {
309                let payload = json!({
310                    "connectionStringOrPubkey": connection_string_or_pubkey,
311                });
312
313                let url = self.build_url(&format!("channels/{}/open", order_id), None)?;
314                let response = self.client.post(url).json(&payload).send().await?;
315
316                self.handle_response(response).await
317            },
318        )
319        .await
320    }
321
322    /// Get minimum 0-conf transaction fee for an order.
323    pub async fn get_min_zero_conf_tx_fee(&self, order_id: &str) -> Result<IBt0ConfMinTxFeeWindow> {
324        self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
325            let url = self.build_url(&format!("channels/{}/min-0conf-tx-fee", order_id), None)?;
326            let response = self.client.get(url).send().await?;
327            self.handle_response(response).await
328        })
329        .await
330    }
331
332    /// Create a new CJIT entry for Just-In-Time channel open.
333    pub async fn create_cjit_entry(
334        &self,
335        channel_size_sat: u64,
336        invoice_sat: u64,
337        invoice_description: &str,
338        node_id: &str,
339        channel_expiry_weeks: u32,
340        options: Option<CreateCjitOptions>,
341    ) -> Result<ICJitEntry> {
342        if channel_size_sat == 0 {
343            return Err(BlocktankError::InvalidParameter {
344                message: ERR_LSP_BALANCE_ZERO.to_string(),
345            });
346        }
347
348        if invoice_sat == 0 {
349            return Err(BlocktankError::InvalidParameter {
350                message: ERR_INVOICE_SAT_ZERO.to_string(),
351            });
352        }
353
354        if node_id.is_empty() {
355            return Err(BlocktankError::InvalidParameter {
356                message: ERR_NODE_ID_EMPTY.to_string(),
357            });
358        }
359
360        let base_payload = json!({
361            "channelSizeSat": channel_size_sat,
362            "invoiceSat": invoice_sat,
363            "invoiceDescription": invoice_description,
364            "channelExpiryWeeks": channel_expiry_weeks,
365            "nodeId": node_id,
366        });
367
368        let payload = self.create_payload(base_payload, options)?;
369        let url = self.build_url("cjit", None)?;
370
371        let response = self.client.post(url).json(&payload).send().await?;
372
373        self.handle_response(response).await
374    }
375
376    /// Get CJIT entry by id. Returns an error if it doesn't find the entry.
377    pub async fn get_cjit_entry(&self, entry_id: &str) -> Result<ICJitEntry> {
378        self.wrap_error_handler(&format!("Failed to fetch cjit entry {}", entry_id), async {
379            let url = self.build_url(&format!("cjit/{}", entry_id), None)?;
380            let response = self.client.get(url).send().await?;
381            self.handle_response(response).await
382        })
383        .await
384    }
385
386    /// Sends a log message from Bitkit to Blocktank. Heavily rate limited.
387    pub async fn bitkit_log(&self, node_id: &str, message: &str) -> Result<()> {
388        self.wrap_error_handler(&format!("Failed to send log for {}", node_id), async {
389            let payload = json!({
390                "nodeId": node_id,
391                "message": message,
392            });
393
394            let url = self.build_url("bitkit/log", None)?;
395            let response = self.client.post(url).json(&payload).send().await?;
396
397            response.error_for_status()?;
398            Ok(())
399        })
400        .await
401    }
402
403    //
404    // Regtest API Methods
405    //
406
407    /// Mines a number of blocks on the regtest network.
408    pub async fn regtest_mine(&self, count: Option<u32>) -> Result<()> {
409        let blocks_to_mine = count.unwrap_or(1);
410        self.wrap_error_handler(
411            &format!("Failed to mine {} blocks", blocks_to_mine),
412            async {
413                let payload = json!({ "count": blocks_to_mine });
414                let url = self.build_url("regtest/chain/mine", None)?;
415                let response = self.client.post(url).json(&payload).send().await?;
416
417                response.error_for_status()?;
418                Ok(())
419            },
420        )
421        .await
422    }
423
424    /// Deposits a number of satoshis to an address on the regtest network.
425    /// Returns the transaction ID of the deposit.
426    pub async fn regtest_deposit(&self, address: &str, amount_sat: Option<u64>) -> Result<String> {
427        self.wrap_error_handler(&format!("Failed to deposit to {}", address), async {
428            let mut payload = json!({
429                "address": address,
430            });
431
432            if let Some(amount_sat) = amount_sat {
433                payload
434                    .as_object_mut()
435                    .unwrap()
436                    .insert("amountSat".to_string(), json!(amount_sat));
437            }
438
439            let url = self.build_url("regtest/chain/deposit", 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    /// Pays an invoice on the regtest network.
449    /// Returns the payment ID.
450    pub async fn regtest_pay(&self, invoice: &str, amount_sat: Option<u64>) -> Result<String> {
451        self.wrap_error_handler("Failed to pay invoice", async {
452            let payload = json!({
453                "invoice": invoice,
454                "amountSat": amount_sat,
455            });
456
457            let url = self.build_url("regtest/channel/pay", None)?;
458            let response = self.client.post(url).json(&payload).send().await?;
459
460            let result = response.error_for_status()?.text().await?;
461            Ok(result)
462        })
463        .await
464    }
465
466    /// Get paid invoice on the regtest network by payment ID.
467    pub async fn regtest_get_payment(&self, payment_id: &str) -> Result<IBtBolt11Invoice> {
468        self.wrap_error_handler(&format!("Failed to get payment {}", payment_id), async {
469            let url = self.build_url(&format!("regtest/channel/pay/{}", payment_id), None)?;
470            let response = self.client.get(url).send().await?;
471            self.handle_response(response).await
472        })
473        .await
474    }
475
476    /// Closes a channel on the regtest network.
477    /// Returns the closing transaction ID.
478    pub async fn regtest_close_channel(
479        &self,
480        funding_tx_id: &str,
481        vout: u32,
482        force_close_after_s: Option<u64>,
483    ) -> Result<String> {
484        let force_desc = if force_close_after_s.is_some() {
485            " force"
486        } else {
487            ""
488        };
489        self.wrap_error_handler(
490            &format!(
491                "Failed to{} close the channel {}:{}",
492                force_desc, funding_tx_id, vout
493            ),
494            async {
495                let mut payload = json!({
496                    "fundingTxId": funding_tx_id,
497                    "vout": vout,
498                });
499
500                if let Some(force_close_after_s) = force_close_after_s {
501                    payload
502                        .as_object_mut()
503                        .unwrap()
504                        .insert("forceCloseAfterS".to_string(), json!(force_close_after_s));
505                }
506
507                let url = self.build_url("regtest/channel/close", None)?;
508                let response = self.client.post(url).json(&payload).send().await?;
509
510                let result = response.error_for_status()?.text().await?;
511                Ok(result)
512            },
513        )
514        .await
515    }
516
517    /// Registers a device with Blocktank
518    pub async fn register_device(
519        &self,
520        device_token: &str,
521        public_key: &str,
522        features: &[String],
523        node_id: &str,
524        iso_timestamp: &str,
525        signature: &str,
526        is_production: Option<bool>,
527        custom_url: Option<&str>,
528    ) -> Result<String> {
529        self.wrap_error_handler("Failed to register device", async {
530            let custom_url = custom_url.or(Some(DEFAULT_NOTIFICATION_URL));
531            let url = self.build_url("device", custom_url)?;
532
533            let mut payload = json!({
534                "deviceToken": device_token,
535                "publicKey": public_key,
536                "features": features,
537                "nodeId": node_id,
538                "isoTimestamp": iso_timestamp,
539                "signature": signature
540            });
541
542            if let Some(is_prod) = is_production {
543                payload
544                    .as_object_mut()
545                    .unwrap()
546                    .insert("isProduction".to_string(), json!(is_prod));
547            }
548
549            let response = self.client.post(url).json(&payload).send().await?;
550
551            let status = response.status();
552            if !status.is_success() {
553                let error_text = response.text().await?;
554                return Err(BlocktankError::Client(format!(
555                    "Device registration failed. Status: {}. Response: {}",
556                    status, error_text
557                )));
558            }
559
560            response.text().await.map_err(|e| e.into())
561        })
562        .await
563    }
564
565    /// Sends a test notification to a registered device
566    pub async fn test_notification(
567        &self,
568        device_token: &str,
569        secret_message: &str,
570        notification_type: Option<&str>,
571        custom_url: Option<&str>,
572    ) -> Result<String> {
573        let notification_type = notification_type.unwrap_or("orderPaymentConfirmed");
574        let custom_url = custom_url.or(Some(DEFAULT_NOTIFICATION_URL));
575        self.wrap_error_handler("Failed to send test notification", async {
576            let url = self.build_url(
577                &format!("device/{}/test-notification", device_token),
578                custom_url,
579            )?;
580
581            let payload = json!({
582                "data": {
583                    "source": "blocktank",
584                    "type": notification_type,
585                    "payload": {
586                        "secretMessage": secret_message
587                    }
588                }
589            });
590
591            let response = self.client.post(url).json(&payload).send().await?;
592
593            let status = response.status();
594            if !status.is_success() {
595                let error_text = response.text().await?;
596                return Err(BlocktankError::Client(format!(
597                    "Test notification failed. Status: {}. Response: {}",
598                    status, error_text
599                )));
600            }
601
602            response.text().await.map_err(|e| e.into())
603        })
604        .await
605    }
606
607    /// Make a payment for a gift invoice
608    pub async fn gift_pay(&self, invoice: &str) -> Result<IGift> {
609        self.wrap_error_handler("Failed to pay gift invoice", async {
610            let payload = json!({
611                "invoice": invoice
612            });
613
614            let url = self.build_url("gift/pay", None)?;
615            let response = self.client.post(url).json(&payload).send().await?;
616
617            self.handle_response(response).await
618        })
619        .await
620    }
621
622    /// Create a gift order
623    pub async fn gift_order(&self, client_node_id: &str, code: &str) -> Result<IGift> {
624        self.wrap_error_handler("Failed to create gift order", async {
625            let payload = json!({
626                "clientNodeId": client_node_id,
627                "code": code
628            });
629
630            let url = self.build_url("gift/order", None)?;
631            let response = self.client.post(url).json(&payload).send().await?;
632
633            self.handle_response(response).await
634        })
635        .await
636    }
637
638    /// Get paid gift payment
639    pub async fn get_gift(&self, gift_id: &str) -> Result<IGift> {
640        self.wrap_error_handler("Failed to get gift", async {
641            let url = self.build_url(&format!("gift/{}", gift_id), None)?;
642            let response = self.client.get(url).send().await?;
643            self.handle_response(response).await
644        })
645        .await
646    }
647
648    /// Get paid payment
649    pub async fn get_payment(&self, payment_id: &str) -> Result<IBtBolt11Invoice> {
650        self.wrap_error_handler("Failed to get payment", async {
651            let url = self.build_url(&format!("payments/{}", payment_id), None)?;
652            let response = self.client.get(url).send().await?;
653            self.handle_response(response).await
654        })
655        .await
656    }
657}