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        let status = response.status();
119        let response_text = response.text().await?;
120
121        match status {
122            s if s.is_success() => {
123                // Handle successful responses
124                if response_text.is_empty() {
125                    // Some endpoints return empty body on success
126                    return Err(BlocktankError::Client(
127                        "Unexpected empty response body".to_string()
128                    ));
129                }
130
131                // Try to parse as JSON first
132                if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
133                    // Check if it's an error disguised as success (with message + type/name)
134                    let is_error_structure = json_value.get("message").is_some()
135                        && (json_value.get("type").is_some() || json_value.get("name").is_some());
136
137                    if is_error_structure {
138                        return self.parse_structured_error(&json_value);
139                    }
140
141                    // Normal JSON response - deserialize to target type
142                    serde_json::from_value::<T>(json_value).map_err(|e| {
143                        BlocktankError::Client(format!(
144                            "Failed to deserialize response: {}. Response: {}",
145                            e, response_text
146                        ))
147                    })
148                } else {
149                    // Not JSON - this shouldn't happen for our JSON endpoints
150                    // but handle gracefully just in case
151                    Err(BlocktankError::Client(format!(
152                        "Expected JSON response but got: {}",
153                        response_text
154                    )))
155                }
156            },
157            StatusCode::BAD_REQUEST => {
158                // 400 errors can be JSON structured or plain strings
159                if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
160                    // Check for structured error format
161                    if json_value.get("message").is_some() {
162                        return self.parse_structured_error(&json_value);
163                    }
164
165                    // Try ApiValidationError format
166                    if let Ok(error) = serde_json::from_value::<ApiValidationError>(json_value) {
167                        if let Some(issue) = error.errors.issues.first() {
168                            return Err(BlocktankError::InvalidParameter {
169                                message: issue.message.clone(),
170                            });
171                        }
172                    }
173                }
174
175                // Plain string error message
176                Err(BlocktankError::Client(format!("Bad request: {}", response_text)))
177            },
178            StatusCode::NOT_FOUND => {
179                Err(BlocktankError::Client(format!("Not found: {}", response_text)))
180            },
181            StatusCode::UNAUTHORIZED => {
182                Err(BlocktankError::Client(format!("Unauthorized: {}", response_text)))
183            },
184            StatusCode::INTERNAL_SERVER_ERROR => {
185                Err(BlocktankError::Client(format!("Server error: {}", response_text)))
186            },
187            status => {
188                // Try to parse error details from JSON if possible
189                if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
190                    if let Some(msg) = json_value.get("message").and_then(|m| m.as_str()) {
191                        return Err(BlocktankError::Client(format!(
192                            "Request failed ({}): {}", status, msg
193                        )));
194                    }
195                }
196
197                Err(BlocktankError::Client(format!(
198                    "Request failed with status {}: {}",
199                    status, response_text
200                )))
201            },
202        }
203    }
204
205    /// Helper method to parse structured error responses
206    fn parse_structured_error<T>(&self, json_value: &serde_json::Value) -> Result<T> {
207        let message = json_value.get("message")
208            .and_then(|m| m.as_str())
209            .unwrap_or("Unknown error");
210
211        let error_type = json_value.get("type")
212            .and_then(|t| t.as_str());
213
214        let error_name = json_value.get("name")
215            .and_then(|n| n.as_str())
216            .unwrap_or("Error");
217
218        // Build error message
219        let full_message = if let Some(err_type) = error_type {
220            format!("{}: {} (type: {})", error_name, message, err_type)
221        } else {
222            format!("{}: {}", error_name, message)
223        };
224
225        // Categorize errors based on type/name
226        match (error_type, error_name) {
227            (Some("ValidationError"), _) | (_, "ValidationError") => {
228                Err(BlocktankError::InvalidParameter { message: full_message })
229            },
230            (Some("WRONG_ORDER_STATE"), _) | (_, "ChannelOpenError") => {
231                Err(BlocktankError::InvalidParameter { message: full_message })
232            },
233            _ => {
234                Err(BlocktankError::Client(full_message))
235            },
236        }
237    }
238
239    /// Internal error wrapper to match TypeScript behavior
240    async fn wrap_error_handler<F, T>(&self, message: &str, f: F) -> Result<T>
241    where
242        F: std::future::Future<Output = Result<T>>,
243    {
244        match f.await {
245            Ok(result) => Ok(result),
246            Err(e) => Err(BlocktankError::BlocktankClient {
247                message: format!("{}: {}", message, e),
248                data: json!(e.to_string()),
249            }),
250        }
251    }
252
253    //
254    // Public API Methods
255    //
256
257    /// Get general service information.
258    pub async fn get_info(&self) -> Result<IBtInfo> {
259        self.wrap_error_handler("Failed to get info", async {
260            let url = self.build_url("info", None)?;
261            let response = self.client.get(url).send().await?;
262            self.handle_response(response).await
263        })
264        .await
265    }
266
267    /// Estimates the fee to create a channel order without actually creating an order.
268    pub async fn estimate_order_fee(
269        &self,
270        lsp_balance_sat: u64,
271        channel_expiry_weeks: u32,
272        options: Option<CreateOrderOptions>,
273    ) -> Result<IBtEstimateFeeResponse> {
274        self.wrap_error_handler("Failed to estimate channel order fee", async {
275            let base_payload = json!({
276                "lspBalanceSat": lsp_balance_sat,
277                "channelExpiryWeeks": channel_expiry_weeks,
278            });
279
280            let payload = self.create_payload(base_payload, options)?;
281            let url = self.build_url("channels/estimate-fee", None)?;
282
283            let response = self.client.post(url).json(&payload).send().await?;
284
285            self.handle_response(response).await
286        })
287        .await
288    }
289
290    /// Estimates the fee to create a channel order without actually creating an order.
291    /// Includes network and service fee.
292    pub async fn estimate_order_fee_full(
293        &self,
294        lsp_balance_sat: u64,
295        channel_expiry_weeks: u32,
296        options: Option<CreateOrderOptions>,
297    ) -> Result<IBtEstimateFeeResponse2> {
298        self.wrap_error_handler("Failed to estimate channel order fee", async {
299            let base_payload = json!({
300                "lspBalanceSat": lsp_balance_sat,
301                "channelExpiryWeeks": channel_expiry_weeks,
302            });
303
304            let payload = self.create_payload(base_payload, options)?;
305            let url = self.build_url("channels/estimate-fee-full", None)?;
306
307            let response = self.client.post(url).json(&payload).send().await?;
308
309            self.handle_response(response).await
310        })
311        .await
312    }
313
314    /// Creates a new channel order
315    pub async fn create_order(
316        &self,
317        lsp_balance_sat: u64,
318        channel_expiry_weeks: u32,
319        options: Option<CreateOrderOptions>,
320    ) -> Result<IBtOrder> {
321        if lsp_balance_sat == 0 {
322            return Err(BlocktankError::InvalidParameter {
323                message: ERR_LSP_BALANCE_ZERO.to_string(),
324            });
325        }
326
327        let base_payload = json!({
328            "lspBalanceSat": lsp_balance_sat,
329            "channelExpiryWeeks": channel_expiry_weeks,
330            "clientBalanceSat": 0,
331        });
332
333        let payload = self.create_payload(base_payload, options)?;
334        let url = self.build_url("channels", None)?;
335
336        let response = self
337            .client
338            .post(url)
339            .json(&payload)
340            .send()
341            .await?;
342
343        self.handle_response(response).await
344    }
345
346    /// Get order by order id. Returns an error if it doesn't find the order.
347    pub async fn get_order(&self, order_id: &str) -> Result<IBtOrder> {
348        self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
349            let url = self.build_url(&format!("channels/{}", order_id), None)?;
350            let response = self.client.get(url).send().await?;
351            self.handle_response(response).await
352        })
353        .await
354    }
355
356    /// Get multiple orders by order ids.
357    pub async fn get_orders(&self, order_ids: &[String]) -> Result<Vec<IBtOrder>> {
358        self.wrap_error_handler(&format!("Failed to fetch orders {:?}", order_ids), async {
359            let url = self.build_url("channels", None)?;
360
361            let query_params: Vec<(&str, &str)> =
362                order_ids.iter().map(|id| ("ids[]", id.as_str())).collect();
363
364            let response = self.client.get(url).query(&query_params).send().await?;
365
366            self.handle_response(response).await
367        })
368        .await
369    }
370
371    /// Open channel to a specific node.
372    pub async fn open_channel(
373        &self,
374        order_id: &str,
375        connection_string_or_pubkey: &str,
376    ) -> Result<IBtOrder> {
377        if order_id.is_empty() || connection_string_or_pubkey.is_empty() {
378            return Err(BlocktankError::InvalidParameter {
379                message: ERR_ORDER_ID_CONNECTION_EMPTY.to_string(),
380            });
381        }
382
383        self.wrap_error_handler(
384            &format!("Failed to open the channel for order {}", order_id),
385            async {
386                let payload = json!({
387                    "connectionStringOrPubkey": connection_string_or_pubkey,
388                });
389
390                let url = self.build_url(&format!("channels/{}/open", order_id), None)?;
391                let response = self.client.post(url).json(&payload).send().await?;
392
393                self.handle_response(response).await
394            },
395        )
396        .await
397    }
398
399    /// Get minimum 0-conf transaction fee for an order.
400    pub async fn get_min_zero_conf_tx_fee(&self, order_id: &str) -> Result<IBt0ConfMinTxFeeWindow> {
401        self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
402            let url = self.build_url(&format!("channels/{}/min-0conf-tx-fee", order_id), None)?;
403            let response = self.client.get(url).send().await?;
404            self.handle_response(response).await
405        })
406        .await
407    }
408
409    /// Create a new CJIT entry for Just-In-Time channel open.
410    pub async fn create_cjit_entry(
411        &self,
412        channel_size_sat: u64,
413        invoice_sat: u64,
414        invoice_description: &str,
415        node_id: &str,
416        channel_expiry_weeks: u32,
417        options: Option<CreateCjitOptions>,
418    ) -> Result<ICJitEntry> {
419        if channel_size_sat == 0 {
420            return Err(BlocktankError::InvalidParameter {
421                message: ERR_LSP_BALANCE_ZERO.to_string(),
422            });
423        }
424
425        if invoice_sat == 0 {
426            return Err(BlocktankError::InvalidParameter {
427                message: ERR_INVOICE_SAT_ZERO.to_string(),
428            });
429        }
430
431        if node_id.is_empty() {
432            return Err(BlocktankError::InvalidParameter {
433                message: ERR_NODE_ID_EMPTY.to_string(),
434            });
435        }
436
437        let base_payload = json!({
438            "channelSizeSat": channel_size_sat,
439            "invoiceSat": invoice_sat,
440            "invoiceDescription": invoice_description,
441            "channelExpiryWeeks": channel_expiry_weeks,
442            "nodeId": node_id,
443        });
444
445        let payload = self.create_payload(base_payload, options)?;
446        let url = self.build_url("cjit", None)?;
447
448        let response = self.client.post(url).json(&payload).send().await?;
449
450        self.handle_response(response).await
451    }
452
453    /// Get CJIT entry by id. Returns an error if it doesn't find the entry.
454    pub async fn get_cjit_entry(&self, entry_id: &str) -> Result<ICJitEntry> {
455        self.wrap_error_handler(&format!("Failed to fetch cjit entry {}", entry_id), async {
456            let url = self.build_url(&format!("cjit/{}", entry_id), None)?;
457            let response = self.client.get(url).send().await?;
458            self.handle_response(response).await
459        })
460        .await
461    }
462
463    /// Sends a log message from Bitkit to Blocktank. Heavily rate limited.
464    pub async fn bitkit_log(&self, node_id: &str, message: &str) -> Result<()> {
465        self.wrap_error_handler(&format!("Failed to send log for {}", node_id), async {
466            let payload = json!({
467                "nodeId": node_id,
468                "message": message,
469            });
470
471            let url = self.build_url("bitkit/log", None)?;
472            let response = self.client.post(url).json(&payload).send().await?;
473
474            response.error_for_status()?;
475            Ok(())
476        })
477        .await
478    }
479
480    //
481    // Regtest API Methods
482    //
483
484    /// Mines a number of blocks on the regtest network.
485    pub async fn regtest_mine(&self, count: Option<u32>) -> Result<()> {
486        let blocks_to_mine = count.unwrap_or(1);
487        self.wrap_error_handler(
488            &format!("Failed to mine {} blocks", blocks_to_mine),
489            async {
490                let payload = json!({ "count": blocks_to_mine });
491                let url = self.build_url("regtest/chain/mine", None)?;
492                let response = self.client.post(url).json(&payload).send().await?;
493
494                response.error_for_status()?;
495                Ok(())
496            },
497        )
498        .await
499    }
500
501    /// Deposits a number of satoshis to an address on the regtest network.
502    /// Returns the transaction ID of the deposit.
503    pub async fn regtest_deposit(&self, address: &str, amount_sat: Option<u64>) -> Result<String> {
504        self.wrap_error_handler(&format!("Failed to deposit to {}", address), async {
505            let mut payload = json!({
506                "address": address,
507            });
508
509            if let Some(amount_sat) = amount_sat {
510                payload
511                    .as_object_mut()
512                    .unwrap()
513                    .insert("amountSat".to_string(), json!(amount_sat));
514            }
515
516            let url = self.build_url("regtest/chain/deposit", None)?;
517            let response = self.client.post(url).json(&payload).send().await?;
518
519            let result = response.error_for_status()?.text().await?;
520            Ok(result)
521        })
522        .await
523    }
524
525    /// Pays an invoice on the regtest network.
526    /// Returns the payment ID.
527    pub async fn regtest_pay(&self, invoice: &str, amount_sat: Option<u64>) -> Result<String> {
528        self.wrap_error_handler("Failed to pay invoice", async {
529            let payload = json!({
530                "invoice": invoice,
531                "amountSat": amount_sat,
532            });
533
534            let url = self.build_url("regtest/channel/pay", None)?;
535            let response = self.client.post(url).json(&payload).send().await?;
536
537            let result = response.error_for_status()?.text().await?;
538            Ok(result)
539        })
540        .await
541    }
542
543    /// Get paid invoice on the regtest network by payment ID.
544    pub async fn regtest_get_payment(&self, payment_id: &str) -> Result<IBtBolt11Invoice> {
545        self.wrap_error_handler(&format!("Failed to get payment {}", payment_id), async {
546            let url = self.build_url(&format!("regtest/channel/pay/{}", payment_id), None)?;
547            let response = self.client.get(url).send().await?;
548            self.handle_response(response).await
549        })
550        .await
551    }
552
553    /// Closes a channel on the regtest network.
554    /// Returns the closing transaction ID.
555    pub async fn regtest_close_channel(
556        &self,
557        funding_tx_id: &str,
558        vout: u32,
559        force_close_after_s: Option<u64>,
560    ) -> Result<String> {
561        let force_desc = if force_close_after_s.is_some() {
562            " force"
563        } else {
564            ""
565        };
566        self.wrap_error_handler(
567            &format!(
568                "Failed to{} close the channel {}:{}",
569                force_desc, funding_tx_id, vout
570            ),
571            async {
572                let mut payload = json!({
573                    "fundingTxId": funding_tx_id,
574                    "vout": vout,
575                });
576
577                if let Some(force_close_after_s) = force_close_after_s {
578                    payload
579                        .as_object_mut()
580                        .unwrap()
581                        .insert("forceCloseAfterSec".to_string(), json!(force_close_after_s));
582                }
583
584                let url = self.build_url("regtest/channel/close", None)?;
585                let response = self.client.post(url).json(&payload).send().await?;
586
587                let result = response.error_for_status()?.text().await?;
588                Ok(result)
589            },
590        )
591        .await
592    }
593
594    /// Registers a device with Blocktank
595    pub async fn register_device(
596        &self,
597        device_token: &str,
598        public_key: &str,
599        features: &[String],
600        node_id: &str,
601        iso_timestamp: &str,
602        signature: &str,
603        is_production: Option<bool>,
604        custom_url: Option<&str>,
605    ) -> Result<String> {
606        self.wrap_error_handler("Failed to register device", async {
607            let custom_url = custom_url.or(Some(DEFAULT_NOTIFICATION_URL));
608            let url = self.build_url("device", custom_url)?;
609
610            let mut payload = json!({
611                "deviceToken": device_token,
612                "publicKey": public_key,
613                "features": features,
614                "nodeId": node_id,
615                "isoTimestamp": iso_timestamp,
616                "signature": signature
617            });
618
619            if let Some(is_prod) = is_production {
620                payload
621                    .as_object_mut()
622                    .unwrap()
623                    .insert("isProduction".to_string(), json!(is_prod));
624            }
625
626            let response = self.client.post(url).json(&payload).send().await?;
627
628            let status = response.status();
629            if !status.is_success() {
630                let error_text = response.text().await?;
631                return Err(BlocktankError::Client(format!(
632                    "Device registration failed. Status: {}. Response: {}",
633                    status, error_text
634                )));
635            }
636
637            response.text().await.map_err(|e| e.into())
638        })
639        .await
640    }
641
642    /// Sends a test notification to a registered device
643    pub async fn test_notification(
644        &self,
645        device_token: &str,
646        secret_message: &str,
647        notification_type: Option<&str>,
648        custom_url: Option<&str>,
649    ) -> Result<String> {
650        let notification_type = notification_type.unwrap_or("orderPaymentConfirmed");
651        let custom_url = custom_url.or(Some(DEFAULT_NOTIFICATION_URL));
652        self.wrap_error_handler("Failed to send test notification", async {
653            let url = self.build_url(
654                &format!("device/{}/test-notification", device_token),
655                custom_url,
656            )?;
657
658            let payload = json!({
659                "data": {
660                    "source": "blocktank",
661                    "type": notification_type,
662                    "payload": {
663                        "secretMessage": secret_message
664                    }
665                }
666            });
667
668            let response = self.client.post(url).json(&payload).send().await?;
669
670            let status = response.status();
671            if !status.is_success() {
672                let error_text = response.text().await?;
673                return Err(BlocktankError::Client(format!(
674                    "Test notification failed. Status: {}. Response: {}",
675                    status, error_text
676                )));
677            }
678
679            response.text().await.map_err(|e| e.into())
680        })
681        .await
682    }
683
684    /// Make a payment for a gift invoice
685    pub async fn gift_pay(&self, invoice: &str) -> Result<IGift> {
686        self.wrap_error_handler("Failed to pay gift invoice", async {
687            let payload = json!({
688                "invoice": invoice
689            });
690
691            let url = self.build_url("gift/pay", None)?;
692            let response = self.client.post(url).json(&payload).send().await?;
693
694            self.handle_response(response).await
695        })
696        .await
697    }
698
699    /// Create a gift order
700    pub async fn gift_order(&self, client_node_id: &str, code: &str) -> Result<IGift> {
701        self.wrap_error_handler("Failed to create gift order", async {
702            let payload = json!({
703                "clientNodeId": client_node_id,
704                "code": code
705            });
706
707            let url = self.build_url("gift/order", None)?;
708            let response = self.client.post(url).json(&payload).send().await?;
709
710            self.handle_response(response).await
711        })
712        .await
713    }
714
715    /// Get paid gift payment
716    pub async fn get_gift(&self, gift_id: &str) -> Result<IGift> {
717        self.wrap_error_handler("Failed to get gift", async {
718            let url = self.build_url(&format!("gift/{}", gift_id), None)?;
719            let response = self.client.get(url).send().await?;
720            self.handle_response(response).await
721        })
722        .await
723    }
724
725    /// Get paid payment
726    pub async fn get_payment(&self, payment_id: &str) -> Result<IBtBolt11Invoice> {
727        self.wrap_error_handler("Failed to get payment", async {
728            let url = self.build_url(&format!("payments/{}", payment_id), None)?;
729            let response = self.client.get(url).send().await?;
730            self.handle_response(response).await
731        })
732        .await
733    }
734}