rust_blocktank_client/
client.rs

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    /// 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 => 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    /// Internal error wrapper to match TypeScript behavior
144    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    //
158    // Public API Methods
159    //
160
161    /// Get general service information.
162    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    /// Estimates the fee to create a channel order without actually creating an order.
172    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    /// Estimates the fee to create a channel order without actually creating an order.
195    /// Includes network and service fee.
196    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    /// Creates a new channel order
219    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    /// Get order by order id. Returns an error if it doesn't find the order.
252    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    /// Get multiple orders by order ids.
262    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    /// Open channel to a specific node.
277    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    /// Get minimum 0-conf transaction fee for an order.
305    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    /// Create a new CJIT entry for Just-In-Time channel open.
315    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    /// Get CJIT entry by id. Returns an error if it doesn't find the entry.
359    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    /// Sends a log message from Bitkit to Blocktank. Heavily rate limited.
369    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    //
386    // Regtest API Methods
387    //
388
389    /// Mines a number of blocks on the regtest network.
390    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    /// Deposits a number of satoshis to an address on the regtest network.
407    /// Returns the transaction ID of the deposit.
408    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    /// Pays an invoice on the regtest network.
431    /// Returns the payment ID.
432    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    /// Get paid invoice on the regtest network by payment ID.
449    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    /// Closes a channel on the regtest network.
459    /// Returns the closing transaction ID.
460    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    /// Registers a device with Blocktank
500    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    /// Sends a test notification to a registered device
540    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}