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