rust_blocktank_client/
client.rs

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    /// Creates a new BlocktankClient instance with the given base URL or default if not provided
27    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    /// Normalizes a URL string to ensure it ends with a slash
39    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    /// Creates an HTTP client with appropriate configuration
52    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(); // Explicitly use rustls
57
58        builder.build()
59            .map_err(|e| BlocktankError::InitializationError {
60                message: format!("{}: {}", ERR_INIT_HTTP_CLIENT, e),
61            })
62    }
63
64    /// Builds a URL by joining the base URL with the given path
65    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    /// Creates a JSON payload from a base payload and options, filtering out null or default values
80    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                    // Filter out null or default values as before.
91                    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    /// Checks if a JSON value represents a default/empty value
103    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    /// Handles API responses with error processing
115    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    /// Internal error wrapper to match TypeScript behavior
147    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    //
163    // Public API Methods
164    //
165
166    /// Get general service information.
167    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    /// Estimates the fee to create a channel order without actually creating an order.
177    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    /// Estimates the fee to create a channel order without actually creating an order.
203    /// Includes network and service fee.
204    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    /// Creates a new channel order
230    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    /// Get order by order id. Returns an error if it doesn't find the order.
262    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    /// Get multiple orders by order ids.
272    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    /// Open channel to a specific node.
292    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    /// Get minimum 0-conf transaction fee for an order.
320    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    /// Create a new CJIT entry for Just-In-Time channel open.
330    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    /// Get CJIT entry by id. Returns an error if it doesn't find the entry.
378    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    /// Sends a log message from Bitkit to Blocktank. Heavily rate limited.
388    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    //
408    // Regtest API Methods
409    //
410
411    /// Mines a number of blocks on the regtest network.
412    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    /// Deposits a number of satoshis to an address on the regtest network.
429    /// Returns the transaction ID of the deposit.
430    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    /// Pays an invoice on the regtest network.
457    /// Returns the payment ID.
458    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    /// Get paid invoice on the regtest network by payment ID.
482    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    /// Closes a channel on the regtest network.
492    /// Returns the closing transaction ID.
493    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    /// Registers a device with Blocktank
527    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    /// Sends a test notification to a registered device
571    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}