use crate::{
error::{
BlocktankError, Result, ERR_INIT_HTTP_CLIENT,
ERR_INVALID_REQUEST_PARAMS, ERR_INVOICE_SAT_ZERO, ERR_LSP_BALANCE_ZERO, ERR_NODE_ID_EMPTY,
ERR_ORDER_ID_CONNECTION_EMPTY,
},
types::blocktank::*,
};
use reqwest::{Client, ClientBuilder, Response, StatusCode};
use serde_json::{json, Value};
use std::time::Duration;
use url::Url;
const DEFAULT_BASE_URL: &str = "https://api1.blocktank.to/api";
const DEFAULT_NOTIFICATION_URL: &str = "https://api.stag0.blocktank.to/notifications/api";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Clone, Debug)]
pub struct BlocktankClient {
base_url: Url,
client: Client,
}
impl BlocktankClient {
pub fn new(base_url: Option<&str>) -> Result<Self> {
let base = base_url.unwrap_or(DEFAULT_BASE_URL);
let base_url = Self::normalize_url(base)?;
let client = Self::create_http_client()?;
Ok(Self { base_url, client })
}
fn normalize_url(url_str: &str) -> Result<Url> {
let fixed = if !url_str.ends_with('/') {
format!("{}/", url_str)
} else {
url_str.to_string()
};
Url::parse(&fixed).map_err(|e| BlocktankError::InitializationError {
message: format!("Invalid URL: {}", e),
})
}
fn create_http_client() -> Result<Client> {
let builder = ClientBuilder::new().timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
#[cfg(feature = "rustls-tls")]
let builder = builder.use_rustls_tls();
builder
.build()
.map_err(|e| BlocktankError::InitializationError {
message: format!("{}: {}", ERR_INIT_HTTP_CLIENT, e),
})
}
fn build_url(&self, path: &str, custom_url: Option<&str>) -> Result<Url> {
if custom_url.is_some() {
let base = custom_url.unwrap_or(DEFAULT_NOTIFICATION_URL);
let base_url = Self::normalize_url(base)?;
base_url
.join(path)
.map_err(|e| BlocktankError::Client(format!("Failed to build URL: {}", e)))
} else {
self.base_url
.join(path)
.map_err(|e| BlocktankError::Client(format!("Failed to build URL: {}", e)))
}
}
fn create_payload<T>(&self, base_payload: Value, options: Option<T>) -> Result<Value>
where
T: serde::Serialize,
{
let mut payload = base_payload;
if let Some(opts) = options {
let options_json = serde_json::to_value(opts).map_err(|e| {
BlocktankError::Client(format!("Failed to serialize options: {}", e))
})?;
if let Value::Object(options_map) = options_json {
if let Value::Object(ref mut payload_map) = payload {
payload_map.extend(
options_map
.into_iter()
.filter(|(_, v)| !v.is_null() && !Self::is_default_value(v)),
);
}
}
}
Ok(payload)
}
fn is_default_value(value: &Value) -> bool {
match value {
Value::Null => true,
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
_ => false,
}
}
async fn handle_response<T>(&self, response: Response) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let status = response.status();
let response_text = response.text().await?;
match status {
s if s.is_success() => {
if response_text.is_empty() {
return Err(BlocktankError::Client(
"Unexpected empty response body".to_string()
));
}
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
let is_error_structure = json_value.get("message").is_some()
&& (json_value.get("type").is_some() || json_value.get("name").is_some());
if is_error_structure {
return self.parse_structured_error(&json_value);
}
serde_json::from_value::<T>(json_value).map_err(|e| {
BlocktankError::Client(format!(
"Failed to deserialize response: {}. Response: {}",
e, response_text
))
})
} else {
Err(BlocktankError::Client(format!(
"Expected JSON response but got: {}",
response_text
)))
}
},
StatusCode::BAD_REQUEST => {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
if json_value.get("message").is_some() {
return self.parse_structured_error(&json_value);
}
if let Ok(error) = serde_json::from_value::<ApiValidationError>(json_value) {
if let Some(issue) = error.errors.issues.first() {
return Err(BlocktankError::InvalidParameter {
message: issue.message.clone(),
});
}
}
}
Err(BlocktankError::Client(format!("Bad request: {}", response_text)))
},
StatusCode::NOT_FOUND => {
Err(BlocktankError::Client(format!("Not found: {}", response_text)))
},
StatusCode::UNAUTHORIZED => {
Err(BlocktankError::Client(format!("Unauthorized: {}", response_text)))
},
StatusCode::INTERNAL_SERVER_ERROR => {
Err(BlocktankError::Client(format!("Server error: {}", response_text)))
},
status => {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
if let Some(msg) = json_value.get("message").and_then(|m| m.as_str()) {
return Err(BlocktankError::Client(format!(
"Request failed ({}): {}", status, msg
)));
}
}
Err(BlocktankError::Client(format!(
"Request failed with status {}: {}",
status, response_text
)))
},
}
}
fn parse_structured_error<T>(&self, json_value: &serde_json::Value) -> Result<T> {
let message = json_value.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
let error_type = json_value.get("type")
.and_then(|t| t.as_str());
let error_name = json_value.get("name")
.and_then(|n| n.as_str())
.unwrap_or("Error");
let full_message = if let Some(err_type) = error_type {
format!("{}: {} (type: {})", error_name, message, err_type)
} else {
format!("{}: {}", error_name, message)
};
match (error_type, error_name) {
(Some("ValidationError"), _) | (_, "ValidationError") => {
Err(BlocktankError::InvalidParameter { message: full_message })
},
(Some("WRONG_ORDER_STATE"), _) | (_, "ChannelOpenError") => {
Err(BlocktankError::InvalidParameter { message: full_message })
},
_ => {
Err(BlocktankError::Client(full_message))
},
}
}
async fn wrap_error_handler<F, T>(&self, message: &str, f: F) -> Result<T>
where
F: std::future::Future<Output = Result<T>>,
{
match f.await {
Ok(result) => Ok(result),
Err(e) => Err(BlocktankError::BlocktankClient {
message: format!("{}: {}", message, e),
data: json!(e.to_string()),
}),
}
}
pub async fn get_info(&self) -> Result<IBtInfo> {
self.wrap_error_handler("Failed to get info", async {
let url = self.build_url("info", None)?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn estimate_order_fee(
&self,
lsp_balance_sat: u64,
channel_expiry_weeks: u32,
options: Option<CreateOrderOptions>,
) -> Result<IBtEstimateFeeResponse> {
self.wrap_error_handler("Failed to estimate channel order fee", async {
let base_payload = json!({
"lspBalanceSat": lsp_balance_sat,
"channelExpiryWeeks": channel_expiry_weeks,
});
let payload = self.create_payload(base_payload, options)?;
let url = self.build_url("channels/estimate-fee", None)?;
let response = self.client.post(url).json(&payload).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn estimate_order_fee_full(
&self,
lsp_balance_sat: u64,
channel_expiry_weeks: u32,
options: Option<CreateOrderOptions>,
) -> Result<IBtEstimateFeeResponse2> {
self.wrap_error_handler("Failed to estimate channel order fee", async {
let base_payload = json!({
"lspBalanceSat": lsp_balance_sat,
"channelExpiryWeeks": channel_expiry_weeks,
});
let payload = self.create_payload(base_payload, options)?;
let url = self.build_url("channels/estimate-fee-full", None)?;
let response = self.client.post(url).json(&payload).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn create_order(
&self,
lsp_balance_sat: u64,
channel_expiry_weeks: u32,
options: Option<CreateOrderOptions>,
) -> Result<IBtOrder> {
if lsp_balance_sat == 0 {
return Err(BlocktankError::InvalidParameter {
message: ERR_LSP_BALANCE_ZERO.to_string(),
});
}
let base_payload = json!({
"lspBalanceSat": lsp_balance_sat,
"channelExpiryWeeks": channel_expiry_weeks,
"clientBalanceSat": 0,
});
let payload = self.create_payload(base_payload, options)?;
let url = self.build_url("channels", None)?;
let response = self
.client
.post(url)
.json(&payload)
.send()
.await?;
self.handle_response(response).await
}
pub async fn get_order(&self, order_id: &str) -> Result<IBtOrder> {
self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
let url = self.build_url(&format!("channels/{}", order_id), None)?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn get_orders(&self, order_ids: &[String]) -> Result<Vec<IBtOrder>> {
self.wrap_error_handler(&format!("Failed to fetch orders {:?}", order_ids), async {
let url = self.build_url("channels", None)?;
let query_params: Vec<(&str, &str)> =
order_ids.iter().map(|id| ("ids[]", id.as_str())).collect();
let response = self.client.get(url).query(&query_params).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn open_channel(
&self,
order_id: &str,
connection_string_or_pubkey: &str,
) -> Result<IBtOrder> {
if order_id.is_empty() || connection_string_or_pubkey.is_empty() {
return Err(BlocktankError::InvalidParameter {
message: ERR_ORDER_ID_CONNECTION_EMPTY.to_string(),
});
}
self.wrap_error_handler(
&format!("Failed to open the channel for order {}", order_id),
async {
let payload = json!({
"connectionStringOrPubkey": connection_string_or_pubkey,
});
let url = self.build_url(&format!("channels/{}/open", order_id), None)?;
let response = self.client.post(url).json(&payload).send().await?;
self.handle_response(response).await
},
)
.await
}
pub async fn get_min_zero_conf_tx_fee(&self, order_id: &str) -> Result<IBt0ConfMinTxFeeWindow> {
self.wrap_error_handler(&format!("Failed to fetch order {}", order_id), async {
let url = self.build_url(&format!("channels/{}/min-0conf-tx-fee", order_id), None)?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn create_cjit_entry(
&self,
channel_size_sat: u64,
invoice_sat: u64,
invoice_description: &str,
node_id: &str,
channel_expiry_weeks: u32,
options: Option<CreateCjitOptions>,
) -> Result<ICJitEntry> {
if channel_size_sat == 0 {
return Err(BlocktankError::InvalidParameter {
message: ERR_LSP_BALANCE_ZERO.to_string(),
});
}
if invoice_sat == 0 {
return Err(BlocktankError::InvalidParameter {
message: ERR_INVOICE_SAT_ZERO.to_string(),
});
}
if node_id.is_empty() {
return Err(BlocktankError::InvalidParameter {
message: ERR_NODE_ID_EMPTY.to_string(),
});
}
let base_payload = json!({
"channelSizeSat": channel_size_sat,
"invoiceSat": invoice_sat,
"invoiceDescription": invoice_description,
"channelExpiryWeeks": channel_expiry_weeks,
"nodeId": node_id,
});
let payload = self.create_payload(base_payload, options)?;
let url = self.build_url("cjit", None)?;
let response = self.client.post(url).json(&payload).send().await?;
self.handle_response(response).await
}
pub async fn get_cjit_entry(&self, entry_id: &str) -> Result<ICJitEntry> {
self.wrap_error_handler(&format!("Failed to fetch cjit entry {}", entry_id), async {
let url = self.build_url(&format!("cjit/{}", entry_id), None)?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn bitkit_log(&self, node_id: &str, message: &str) -> Result<()> {
self.wrap_error_handler(&format!("Failed to send log for {}", node_id), async {
let payload = json!({
"nodeId": node_id,
"message": message,
});
let url = self.build_url("bitkit/log", None)?;
let response = self.client.post(url).json(&payload).send().await?;
response.error_for_status()?;
Ok(())
})
.await
}
pub async fn regtest_mine(&self, count: Option<u32>) -> Result<()> {
let blocks_to_mine = count.unwrap_or(1);
self.wrap_error_handler(
&format!("Failed to mine {} blocks", blocks_to_mine),
async {
let payload = json!({ "count": blocks_to_mine });
let url = self.build_url("regtest/chain/mine", None)?;
let response = self.client.post(url).json(&payload).send().await?;
response.error_for_status()?;
Ok(())
},
)
.await
}
pub async fn regtest_deposit(&self, address: &str, amount_sat: Option<u64>) -> Result<String> {
self.wrap_error_handler(&format!("Failed to deposit to {}", address), async {
let mut payload = json!({
"address": address,
});
if let Some(amount_sat) = amount_sat {
payload
.as_object_mut()
.unwrap()
.insert("amountSat".to_string(), json!(amount_sat));
}
let url = self.build_url("regtest/chain/deposit", None)?;
let response = self.client.post(url).json(&payload).send().await?;
let result = response.error_for_status()?.text().await?;
Ok(result)
})
.await
}
pub async fn regtest_pay(&self, invoice: &str, amount_sat: Option<u64>) -> Result<String> {
self.wrap_error_handler("Failed to pay invoice", async {
let payload = json!({
"invoice": invoice,
"amountSat": amount_sat,
});
let url = self.build_url("regtest/channel/pay", None)?;
let response = self.client.post(url).json(&payload).send().await?;
let result = response.error_for_status()?.text().await?;
Ok(result)
})
.await
}
pub async fn regtest_get_payment(&self, payment_id: &str) -> Result<IBtBolt11Invoice> {
self.wrap_error_handler(&format!("Failed to get payment {}", payment_id), async {
let url = self.build_url(&format!("regtest/channel/pay/{}", payment_id), None)?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn regtest_close_channel(
&self,
funding_tx_id: &str,
vout: u32,
force_close_after_s: Option<u64>,
) -> Result<String> {
let force_desc = if force_close_after_s.is_some() {
" force"
} else {
""
};
self.wrap_error_handler(
&format!(
"Failed to{} close the channel {}:{}",
force_desc, funding_tx_id, vout
),
async {
let mut payload = json!({
"fundingTxId": funding_tx_id,
"vout": vout,
});
if let Some(force_close_after_s) = force_close_after_s {
payload
.as_object_mut()
.unwrap()
.insert("forceCloseAfterSec".to_string(), json!(force_close_after_s));
}
let url = self.build_url("regtest/channel/close", None)?;
let response = self.client.post(url).json(&payload).send().await?;
let result = response.error_for_status()?.text().await?;
Ok(result)
},
)
.await
}
pub async fn register_device(
&self,
device_token: &str,
public_key: &str,
features: &[String],
node_id: &str,
iso_timestamp: &str,
signature: &str,
is_production: Option<bool>,
custom_url: Option<&str>,
) -> Result<String> {
self.wrap_error_handler("Failed to register device", async {
let custom_url = custom_url.or(Some(DEFAULT_NOTIFICATION_URL));
let url = self.build_url("device", custom_url)?;
let mut payload = json!({
"deviceToken": device_token,
"publicKey": public_key,
"features": features,
"nodeId": node_id,
"isoTimestamp": iso_timestamp,
"signature": signature
});
if let Some(is_prod) = is_production {
payload
.as_object_mut()
.unwrap()
.insert("isProduction".to_string(), json!(is_prod));
}
let response = self.client.post(url).json(&payload).send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await?;
return Err(BlocktankError::Client(format!(
"Device registration failed. Status: {}. Response: {}",
status, error_text
)));
}
response.text().await.map_err(|e| e.into())
})
.await
}
pub async fn test_notification(
&self,
device_token: &str,
secret_message: &str,
notification_type: Option<&str>,
custom_url: Option<&str>,
) -> Result<String> {
let notification_type = notification_type.unwrap_or("orderPaymentConfirmed");
let custom_url = custom_url.or(Some(DEFAULT_NOTIFICATION_URL));
self.wrap_error_handler("Failed to send test notification", async {
let url = self.build_url(
&format!("device/{}/test-notification", device_token),
custom_url,
)?;
let payload = json!({
"data": {
"source": "blocktank",
"type": notification_type,
"payload": {
"secretMessage": secret_message
}
}
});
let response = self.client.post(url).json(&payload).send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await?;
return Err(BlocktankError::Client(format!(
"Test notification failed. Status: {}. Response: {}",
status, error_text
)));
}
response.text().await.map_err(|e| e.into())
})
.await
}
pub async fn gift_pay(&self, invoice: &str) -> Result<IGift> {
self.wrap_error_handler("Failed to pay gift invoice", async {
let payload = json!({
"invoice": invoice
});
let url = self.build_url("gift/pay", None)?;
let response = self.client.post(url).json(&payload).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn gift_order(&self, client_node_id: &str, code: &str) -> Result<IGift> {
self.wrap_error_handler("Failed to create gift order", async {
let payload = json!({
"clientNodeId": client_node_id,
"code": code
});
let url = self.build_url("gift/order", None)?;
let response = self.client.post(url).json(&payload).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn get_gift(&self, gift_id: &str) -> Result<IGift> {
self.wrap_error_handler("Failed to get gift", async {
let url = self.build_url(&format!("gift/{}", gift_id), None)?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
})
.await
}
pub async fn get_payment(&self, payment_id: &str) -> Result<IBtBolt11Invoice> {
self.wrap_error_handler("Failed to get payment", async {
let url = self.build_url(&format!("payments/{}", payment_id), None)?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
})
.await
}
}