From eb472a738275c1dab8b3e4a43e164eb46e566a08 Mon Sep 17 00:00:00 2001 From: Eric P Date: Tue, 16 Jul 2024 01:45:05 -0500 Subject: [PATCH] Add partial CLN support with potential for others in the future --- Cargo.toml | 1 + config_spec.toml | 32 +++-- src/handler.rs | 12 +- src/lightning.rs | 291 ++++++++++++++------------------------ src/lnclient.rs | 84 +++++++++++ src/lnclient/clnclient.rs | 266 ++++++++++++++++++++++++++++++++++ src/lnclient/lndclient.rs | 212 +++++++++++++++++++++++++++ src/main.rs | 286 +++++++++++++++++++++---------------- 8 files changed, 864 insertions(+), 320 deletions(-) create mode 100644 src/lnclient.rs create mode 100644 src/lnclient/clnclient.rs create mode 100644 src/lnclient/lndclient.rs diff --git a/Cargo.toml b/Cargo.toml index 884df2b..5e0b5af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ axum-extra = { version = "0.9.3", features = ["cookie"] } axum_typed_multipart = "0.11.0" tempfile = "3.10.1" tower-http = { version = "0.5.2", features = ["fs", "cors"] } +async-trait = "0.1.81" [build-dependencies] configure_me_codegen = "0.4.1" diff --git a/config_spec.toml b/config_spec.toml index a04b2c9..b1904e0 100644 --- a/config_spec.toml +++ b/config_spec.toml @@ -8,16 +8,6 @@ name = "sound_dir" type = "String" doc = "The location to store boost sounds." -[[param]] -name = "macaroon" -type = "String" -doc = "The location of the macaroon file." - -[[param]] -name = "cert" -type = "String" -doc = "The location of the tls certificate file." - [[param]] name = "listen_port" type = "u16" @@ -31,4 +21,24 @@ doc = "The password to use to access Helipad." [[param]] name = "lnd_url" type = "String" -doc = "The url and port of the LND grpc api." \ No newline at end of file +doc = "The url and port of the LND grpc api." + +[[param]] +name = "macaroon" +type = "String" +doc = "The location of the LND macaroon file." + +[[param]] +name = "cert" +type = "String" +doc = "The location of the LND tls certificate file." + +[[param]] +name = "cln_rest_url" +type = "String" +doc = "The url and port of the CLN REST API (e.g. localhost:3001)." + +[[param]] +name = "cln_rest_rune_path" +type = "String" +doc = "The location of the CLN REST Rune." diff --git a/src/handler.rs b/src/handler.rs index 1428388..ef3d0b4 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -25,6 +25,8 @@ use std::string::String; use url::Url; use tempfile::NamedTempFile; +use crate::lnclient; + //Structs and Enums ------------------------------------------------------------------------------------------ #[derive(Debug, Serialize, Deserialize)] struct JwtClaims { @@ -461,10 +463,12 @@ pub async fn api_v1_reply( }); let helipad_config = state.helipad_config.clone(); - let lightning = match lightning::connect_to_lnd(helipad_config.node_address, helipad_config.cert_path, helipad_config.macaroon_path).await { - Some(lndconn) => lndconn, - None => { - return (StatusCode::INTERNAL_SERVER_ERROR, "** Error connecting to LND.").into_response(); + + let lightning = match lnclient::connect(&helipad_config).await { + Ok(conn) => conn, + Err(e) => { + eprintln!("** Error connecting to node: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "** Error connecting to node.").into_response(); } }; diff --git a/src/lightning.rs b/src/lightning.rs index e05d090..d781bb7 100644 --- a/src/lightning.rs +++ b/src/lightning.rs @@ -1,12 +1,8 @@ use crate::podcastindex; -use data_encoding::HEXLOWER; -use lnd::lnrpc::lnrpc::{SendRequest, Payment, Invoice}; +use crate::lnclient::{LNClient, Invoice, Payment, Boost}; use serde_json::Value; -use sha2::{Sha256, Digest}; use std::collections::HashMap; -use std::fs; use std::error::Error; -use rand::RngCore; use serde::{Deserialize, Deserializer}; // TLV keys (see https://github.com/satoshisstream/satoshis.stream/blob/main/TLV_registry.md) @@ -125,43 +121,6 @@ impl std::fmt::Display for BoostError { impl std::error::Error for BoostError {} -pub async fn connect_to_lnd(node_address: String, cert_path: String, macaroon_path: String) -> Option { - let cert: Vec; - match fs::read(cert_path.clone()) { - Ok(cert_content) => { - // println!(" - Success."); - cert = cert_content; - } - Err(_) => { - eprintln!("Cannot find a valid tls.cert file"); - return None; - } - } - - let macaroon: Vec; - match fs::read(macaroon_path.clone()) { - Ok(macaroon_content) => { - // println!(" - Success."); - macaroon = macaroon_content; - } - Err(_) => { - eprintln!("Cannot find a valid admin.macaroon file"); - return None; - } - } - - //Make the connection to LND - let lightning = lnd::Lnd::connect_with_macaroon(node_address.clone(), &cert, &macaroon).await; - - if lightning.is_err() { - println!("Could not connect to: [{}] using tls: [{}] and macaroon: [{}]", node_address, cert_path, macaroon_path); - eprintln!("{:#?}", lightning.err()); - return None; - } - - return lightning.ok(); -} - pub async fn resolve_keysend_address(address: &str) -> Result> { if !address.contains('@') { return Err(Box::new(KeysendAddressError("Invalid keysend address".to_string()))); @@ -192,7 +151,7 @@ pub async fn resolve_keysend_address(address: &str) -> Result, custom_value: Option, sats: u64, tlv: Value) -> Result> { +pub async fn send_boost(mut lightning: Box, destination: String, custom_key: Option, custom_value: Option, sats: u64, tlv: Value) -> Result> { // thanks to BrianOfLondon and Mostro for keysend details: // https://peakd.com/@brianoflondon/lightning-keysend-is-strange-and-how-to-send-keysend-payment-in-lightning-with-the-lnd-rest-api-via-python // https://github.com/MostroP2P/mostro/blob/52a4f86c3942c26bd42dc55f1e53db5da9f7542b/src/lightning/mod.rs#L18 @@ -216,76 +175,46 @@ pub async fn send_boost(mut lightning: lnd::Lnd, destination: String, custom_key } } else { - recipient_pubkey = destination; + recipient_pubkey = destination.clone(); if custom_key.is_some() && custom_value.is_some() { recipient_custom_data.insert(custom_key.unwrap(), custom_value.unwrap()); } } - // convert pub key hash to raw bytes - let raw_pubkey = HEXLOWER.decode(recipient_pubkey.as_bytes()).unwrap(); - - // generate 32 random bytes for pre_image - let mut pre_image = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut pre_image); - - // and convert to sha256 hash - let mut hasher = Sha256::new(); - hasher.update(pre_image); - let payment_hash = hasher.finalize(); - // TLV custom records // https://github.com/satoshisstream/satoshis.stream/blob/main/TLV_registry.md let mut dest_custom_records = HashMap::new(); let tlv_json = serde_json::to_string_pretty(&tlv).unwrap(); - dest_custom_records.insert(TLV_KEYSEND, pre_image.to_vec()); + // dest_custom_records.insert(TLV_KEYSEND, pre_image.to_vec()); dest_custom_records.insert(TLV_PODCASTING20, tlv_json.as_bytes().to_vec()); for (key, value) in recipient_custom_data { dest_custom_records.insert(key, value.as_bytes().to_vec()); } - // assemble the lnd payment - let req = SendRequest { - dest: raw_pubkey.clone(), - amt: sats as i64, - payment_hash: payment_hash.to_vec(), - dest_custom_records: dest_custom_records, - ..Default::default() - }; - - // send payment and get payment hash - let response = lnd::Lnd::send_payment_sync(&mut lightning, req).await?; - let sent_payment_hash = HEXLOWER.encode(&response.payment_hash); - - if response.payment_error != "" { - return Err(Box::new(BoostError(response.payment_error.into()))); - } - - // get detailed payment info from list_payments - let payment_list = lnd::Lnd::list_payments(&mut lightning, false, 0, 500, true).await?; - - for payment in payment_list.payments { - if sent_payment_hash == payment.payment_hash { - return Ok(payment); - } - } - - Err(Box::new(BoostError("Failed to find payment sent".into()))) + return lightning.keysend(recipient_pubkey, sats, dest_custom_records).await; } - - -pub async fn parse_podcast_tlv(boost: &mut dbif::BoostRecord, val: &Vec, remote_cache: &mut podcastindex::GuidCache) { +pub fn parse_podcast_tlv(val: &Vec) -> Boost { let tlv = std::str::from_utf8(&val).unwrap(); - println!("TLV: {:#?}", tlv); - boost.tlv = tlv.to_string(); + let mut boost = Boost { + action: 0, + podcast: "".to_string(), + episode: "".to_string(), + message: "".to_string(), + sender: "".to_string(), + app: "".to_string(), + tlv: tlv.to_string(), + value_msat: 0, + value_msat_total: 0, + remote_feed_guid: "".to_string(), + remote_item_guid: "".to_string(), + }; - let json_result = serde_json::from_str::(tlv); - match json_result { + match serde_json::from_str::(tlv) { Ok(rawboost) => { //If there was a sat value in the tlv, override the invoice if rawboost.value_msat.is_some() { @@ -333,121 +262,117 @@ pub async fn parse_podcast_tlv(boost: &mut dbif::BoostRecord, val: &Vec, rem } //Fetch podcast/episode name if remote feed/item guid present - if rawboost.remote_feed_guid.is_some() && rawboost.remote_item_guid.is_some() { - let remote_feed_guid = rawboost.remote_feed_guid.unwrap(); - let remote_item_guid = rawboost.remote_item_guid.unwrap(); - - let episode_guid = remote_cache.get(remote_feed_guid, remote_item_guid).await; + if rawboost.remote_feed_guid.is_some() { + boost.remote_feed_guid = rawboost.remote_feed_guid.unwrap(); + } - if let Ok(guid) = episode_guid { - boost.remote_podcast = guid.podcast; - boost.remote_episode = guid.episode; - } + if rawboost.remote_item_guid.is_some() { + boost.remote_item_guid = rawboost.remote_item_guid.unwrap(); } - } + }, Err(e) => { eprintln!("{}", e); } - } + }; + + return boost; } pub async fn parse_boost_from_invoice(invoice: Invoice, remote_cache: &mut podcastindex::GuidCache) -> Option { + if invoice.boostagram.is_none() { + return None; + } - for htlc in invoice.htlcs { + let boost = invoice.boostagram.unwrap(); + + let mut db_boost = dbif::BoostRecord { + index: invoice.index, + time: invoice.time, + value_msat: boost.value_msat, + value_msat_total: boost.value_msat_total, + action: boost.action, + sender: boost.sender, + app: boost.app, + message: boost.message, + podcast: boost.podcast, + episode: boost.episode, + tlv: boost.tlv, + remote_podcast: None, + remote_episode: None, + reply_sent: false, + payment_info: None, + }; - if !htlc.custom_records.contains_key(&TLV_PODCASTING20) { - continue; // ignore invoices without a podcasting 2.0 tlv + //Fetch podcast/episode name if remote feed/item guid present + if boost.remote_feed_guid != "" && boost.remote_item_guid != "" { + match remote_cache.get(boost.remote_feed_guid, boost.remote_item_guid).await { + Ok(guid) => { + db_boost.remote_podcast = guid.podcast; + db_boost.remote_episode = guid.episode; + } + Err(_) => {} } - - //Initialize a boost record - let mut boost = dbif::BoostRecord { - index: invoice.add_index, - time: invoice.settle_date, - value_msat: invoice.amt_paid_sat * 1000, - value_msat_total: invoice.amt_paid_sat * 1000, - action: 0, - sender: "".to_string(), - app: "".to_string(), - message: "".to_string(), - podcast: "".to_string(), - episode: "".to_string(), - tlv: "".to_string(), - remote_podcast: None, - remote_episode: None, - reply_sent: false, - payment_info: None, - }; - - parse_podcast_tlv(&mut boost, &htlc.custom_records[&TLV_PODCASTING20], remote_cache).await; - - return Some(boost); } - return None; + Some(db_boost) } + pub async fn parse_boost_from_payment(payment: Payment, remote_cache: &mut podcastindex::GuidCache) -> Option { + if payment.boostagram.is_none() { + return None; + } - for htlc in payment.htlcs { + let boost = payment.boostagram.unwrap(); - if htlc.route.is_none() { - continue; // no route found - } + let mut db_payment_info = dbif::PaymentRecord { + payment_hash: payment.payment_hash, + pubkey: payment.destination, + custom_key: 0, + custom_value: "".into(), + fee_msat: payment.fee * 1000, + reply_to_idx: None, + }; - let route = htlc.route.unwrap(); - let hopidx = route.hops.len() - 1; - let hop = route.hops[hopidx].clone(); + //Get custom key/value for keysend wallet + for (idx, val) in payment.custom_records { + if idx == TLV_WALLET_KEY || idx == TLV_WALLET_ID || idx == TLV_HIVE_ACCOUNT { + let custom_value = std::str::from_utf8(&val).unwrap().to_string(); - if !hop.custom_records.contains_key(&TLV_PODCASTING20) { - continue; // not a boost payment + db_payment_info.custom_key = idx; + db_payment_info.custom_value = custom_value; } + } - //Initialize a boost record - let mut boost = dbif::BoostRecord { - index: payment.payment_index, - time: payment.creation_time_ns / 1000000000, - value_msat: payment.value_msat, - value_msat_total: payment.value_msat, - action: 0, - sender: "".to_string(), - app: "".to_string(), - message: "".to_string(), - podcast: "".to_string(), - episode: "".to_string(), - tlv: "".to_string(), - remote_podcast: None, - remote_episode: None, - reply_sent: false, - payment_info: Some(dbif::PaymentRecord { - payment_hash: payment.payment_hash.clone(), - pubkey: hop.pub_key.clone(), - custom_key: 0, - custom_value: "".into(), - fee_msat: payment.fee_msat, - reply_to_idx: None, - }), - }; - - for (idx, val) in hop.custom_records { - if idx == TLV_PODCASTING20 { - parse_podcast_tlv(&mut boost, &val, remote_cache).await; - } - else if idx == TLV_WALLET_KEY || idx == TLV_WALLET_ID || idx == TLV_HIVE_ACCOUNT { - let custom_value = std::str::from_utf8(&val).unwrap().to_string(); - - boost.payment_info = Some(dbif::PaymentRecord { - payment_hash: payment.payment_hash.clone(), - pubkey: hop.pub_key.clone(), - custom_key: idx, - custom_value: custom_value, - fee_msat: payment.fee_msat, - reply_to_idx: None, - }); + //Initialize a boost record + let mut db_boost = dbif::BoostRecord { + index: payment.index, + time: payment.time, + value_msat: boost.value_msat, + value_msat_total: boost.value_msat_total, + action: boost.action, + sender: boost.sender, + app: boost.app, + message: boost.message, + podcast: boost.podcast, + episode: boost.episode, + tlv: boost.tlv, + remote_podcast: None, + remote_episode: None, + reply_sent: false, + payment_info: Some(db_payment_info), + }; + + //Fetch podcast/episode name if remote feed/item guid present + if boost.remote_feed_guid != "" && boost.remote_item_guid != "" { + match remote_cache.get(boost.remote_feed_guid, boost.remote_item_guid).await { + Ok(guid) => { + db_boost.remote_podcast = guid.podcast; + db_boost.remote_episode = guid.episode; } + Err(_) => {} } - - return Some(boost); } - return None; -} \ No newline at end of file + Some(db_boost) +} diff --git a/src/lnclient.rs b/src/lnclient.rs new file mode 100644 index 0000000..7799f4c --- /dev/null +++ b/src/lnclient.rs @@ -0,0 +1,84 @@ +use async_trait::async_trait; + +use std::collections::HashMap; +use std::error::Error; + +use crate::HelipadConfig; + +use crate::lnclient::clnclient::CLNClient; +use crate::lnclient::lndclient::LNDClient; + +pub mod lndclient; +pub mod clnclient; + +#[derive(Clone, Debug)] +pub struct NodeInfo { + pub pubkey: String, + pub alias: String, + pub nodetype: String, + pub version: String, +} + +#[derive(Clone, Debug)] +pub struct Boost { + pub action: u8, + pub podcast: String, + pub episode: String, + pub message: String, + pub app: String, + pub remote_feed_guid: String, + pub remote_item_guid: String, + pub sender: String, + pub tlv: String, + pub value_msat: i64, + pub value_msat_total: i64, +} + +#[derive(Clone, Debug)] +pub struct Invoice { + pub index: u64, + pub time: i64, + pub amount: i64, + + pub payment_hash: String, + pub preimage: String, + + pub boostagram: Option, + pub custom_records: HashMap>, +} + +#[derive(Clone, Debug)] +pub struct Payment { + pub index: u64, + pub time: i64, + pub amount: i64, + + pub payment_hash: String, + pub payment_preimage: String, + + pub boostagram: Option, + pub custom_records: HashMap>, + + pub destination: String, + pub fee: i64, +} + +#[async_trait] +pub trait LNClient: Send { + async fn get_info(&mut self) -> Result>; + async fn channel_balance(&mut self) -> Result>; + async fn list_invoices(&mut self, start: u64, limit: u64) -> Result, Box>; + async fn list_payments(&mut self, start: u64, limit: u64) -> Result, Box>; + async fn keysend(&mut self, destination: String, sats: u64, custom_records: HashMap>) -> Result>; +} + +pub async fn connect(config: &HelipadConfig) -> Result, Box> { + let helipad_config = config.clone(); + + if helipad_config.cln_rest_url != "" { + Ok(Box::new(CLNClient::connect(helipad_config.cln_rest_url, helipad_config.cln_rest_rune_path).await?)) + } + else { + Ok(Box::new(LNDClient::connect(helipad_config.lnd_url, helipad_config.lnd_cert_path, helipad_config.lnd_macaroon_path).await?)) + } +} diff --git a/src/lnclient/clnclient.rs b/src/lnclient/clnclient.rs new file mode 100644 index 0000000..f5f3d0e --- /dev/null +++ b/src/lnclient/clnclient.rs @@ -0,0 +1,266 @@ +use async_trait::async_trait; + +use crate::lnclient::{LNClient, NodeInfo, Invoice, Payment}; +use crate::lightning::parse_podcast_tlv; + +use data_encoding::HEXLOWER; + +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::Client; + +use serde_json::json; +use serde_json::Value; + +use std::collections::HashMap; +use std::error::Error; +use std::fs; + + +pub struct CLNClient { + url: String, + client: Client +} + +impl CLNClient { + + pub async fn connect(url: String, rune_path: String) -> Result> { + let rune = fs::read_to_string(rune_path.clone())?; + let rune = rune.trim(); + + let mut headers = HeaderMap::new(); + + if let Ok(hdr) = HeaderValue::from_str(&rune) { + headers.insert("Rune", hdr); + } + else { + eprintln!("unable to add rune"); + } + + let cert = std::fs::read("/cln/regtest/ca.pem")?; + let cert = reqwest::Certificate::from_pem(&cert)?; + + let client = reqwest::Client::builder() + .add_root_certificate(cert) + .default_headers(headers) + .build()?; + + Ok(CLNClient { + url, + client + }) + } + + async fn request(&mut self, url: &str, param: Option) -> Result> { + let full_url = format!("{}/{}", self.url, url); + + let mut js = "".to_string(); + + if let Some(post) = param { + js = serde_json::to_string_pretty(&post).unwrap(); + } + + let response = self.client + .post(&full_url) + .header("Content-Type", "application/json") + .body(js.clone()) + .send() + .await?; + + let body = response + .text() + .await?; + + let json: Value = serde_json::from_str(&body)?; + + Ok(json) + } + + pub fn parse_payment(&mut self, item: &Value) -> Payment { + let mut pay = Payment { + index: item["created_index"].as_u64().unwrap_or_default(), + time: item["created_at"].as_i64().unwrap_or_default(), + amount: 0, + destination: item["destination"].as_str().unwrap_or_default().to_string(), + payment_hash: item["payment_hash"].as_str().unwrap_or_default().to_string(), + payment_preimage: item["payment_preimage"].as_str().unwrap_or_default().to_string(), + fee: 0, + custom_records: HashMap::new(), + boostagram: None, + }; + + if let Some(amount_sent_msat) = item["amount_sent_msat"].as_i64() { + pay.amount = amount_sent_msat / 1000; + } + + if let Some(amount_recv_msat) = item["amount_msat"].as_i64() { + pay.fee = pay.amount - (amount_recv_msat / 1000); + } + + return pay; + } + +} + +#[async_trait] +impl LNClient for CLNClient { + + async fn get_info(&mut self) -> Result> { + let json = self.request("/v1/getinfo", None).await?; + + let info = NodeInfo { + pubkey: json["id"].as_str().unwrap_or_default().to_string(), + alias: json["alias"].as_str().unwrap_or_default().to_string(), + version: json["version"].as_str().unwrap_or_default().to_string(), + nodetype: "CLN".to_string(), + }; + + Ok(info) + } + + + async fn channel_balance(&mut self) -> Result> { + let json = self.request("/v1/bkpr-listbalances", None).await?; + + let mut local_balance = 0; + + if let Some(accounts) = json["accounts"].as_array() { + for account in accounts { + let name = account["account"].as_str().unwrap_or_default(); + if name == "wallet" { + continue; // skip onchain balance + } + + let resolved = account["account_resolved"].as_bool().unwrap_or_default(); + if resolved { + continue; // closed and resolved channel + } + + if let Some(balances) = account["balances"].as_array() { + for balance in balances { + let balance = balance["balance_msat"].as_i64().unwrap_or_default(); + local_balance += balance / 1000; + } + } + } + } + + Ok(local_balance) + } + + async fn list_invoices(&mut self, start: u64, limit: u64) -> Result, Box> { + let body = json!({ + "index": "created", + "start": start + 1, + "limit": limit, + }); + + let json = self.request("/v1/listinvoices", Some(body)).await?; + + let mut invoices: Vec = Vec::new(); + + if let Value::Array(items) = &json["invoices"] { + for item in items { + let mut inv = Invoice { + index: item["created_index"].as_u64().unwrap_or_default(), + time: item["paid_at"].as_i64().unwrap_or_default(), + amount: 0, + payment_hash: item["payment_hash"].as_str().unwrap_or_default().to_string(), + preimage: item["payment_preimage"].as_str().unwrap_or_default().to_string(), + custom_records: HashMap::new(), + boostagram: None, + }; + + if let Some(amt) = item["amount_received_msat"].as_i64() { + inv.amount = amt / 1000; + } + else if let Some(amt) = item["amount_msat"].as_i64() { + inv.amount = amt / 1000; + } + + // CLN stuffs TLVs into the description field + if let Value::String(desc) = &item["description"] { + if desc.starts_with("keysend: {") { + // grab everything after 'keysend: ' + let mut chars = desc[9..].chars(); + let mut val = String::new(); + + // remove escaping around the tlv json + while let Some(c) = chars.next() { + val.push(match c { + '\\' => chars.next().unwrap_or_default(), + c => c, + }); + } + + // attempt to parse as podcast tlv + inv.boostagram = Some(parse_podcast_tlv(&val.into())); + } + } + + invoices.push(inv); + } + } + + Ok(invoices) + } + + + async fn list_payments(&mut self, start: u64, limit: u64) -> Result, Box> { + let body = json!({ + "index": "created", + "start": start + 1, + "limit": limit, + "status": "complete", + }); + + let json = self.request("/v1/listsendpays", Some(body)).await?; + + let mut payments: Vec = Vec::new(); + + if let Value::Array(items) = &json["payments"] { + for item in items { + payments.push(self.parse_payment(item)); + } + } + + Ok(payments) + } + + async fn keysend(&mut self, destination: String, sats: u64, custom_records: HashMap>) -> Result> { + let mut extratlvs: HashMap = HashMap::new(); + + for (idx, val) in custom_records { + let value = HEXLOWER.encode(&val); + extratlvs.insert(idx, value); + } + + // send keysend payment + let body = json!({ + "destination": destination, + "amount_msat": sats * 1000, + "extratlvs": extratlvs, + }); + + let json = self.request("/v1/keysend", Some(body)).await?; + + if let Value::String(message) = &json["message"] { + return Err(message.to_string().into()); + } + + // look up full payment info in listsendpays + let body = json!({ + "payment_hash": json["payment_hash"].as_str().unwrap_or_default(), + }); + + let json = self.request("/v1/listsendpays", Some(body)).await?; + + if let Value::Array(items) = &json["payments"] { + for item in items { + return Ok(self.parse_payment(item)); + } + } + + Err("unable to find keysend payment".into()) + } + +} \ No newline at end of file diff --git a/src/lnclient/lndclient.rs b/src/lnclient/lndclient.rs new file mode 100644 index 0000000..cbb9b53 --- /dev/null +++ b/src/lnclient/lndclient.rs @@ -0,0 +1,212 @@ +use async_trait::async_trait; +use crate::lightning; + +use crate::lnclient::{LNClient, NodeInfo, Invoice, Payment}; +use crate::lightning::parse_podcast_tlv; + +use data_encoding::HEXLOWER; + +use lnd::lnrpc::lnrpc::{SendRequest, Payment as LndPayment}; + +use rand::RngCore; + +use sha2::{Sha256, Digest}; + +use std::collections::HashMap; +use std::error::Error; +use std::fs; +use std::vec::Vec; + + +pub struct LNDClient { + client: lnd::Lnd, + // remote_cache: GuidCache, +} + +impl LNDClient { + + pub async fn connect(node_address: String, cert_path: String, macaroon_path: String) -> Result> { + + let cert: Vec = fs::read(cert_path.clone())?; + let macaroon: Vec = fs::read(macaroon_path.clone())?; + + //Make the connection to LND + let client = lnd::Lnd::connect_with_macaroon(node_address.clone(), &cert, &macaroon).await?; + + // let remote_cache = podcastindex::GuidCache::new(1000); + + Ok(LNDClient { + client, + // remote_cache + }) + } + + fn parse_payment(&mut self, item: &LndPayment) -> Payment { + let mut pay = Payment { + index: item.payment_index, + time: item.creation_time_ns / 1000000000, + amount: item.value_sat, + + destination: String::new(), // hop pubkey + payment_hash: item.payment_hash.clone(), + payment_preimage: item.payment_preimage.clone(), + + fee: item.fee_sat, + + custom_records: HashMap::new(), + boostagram: None, + }; + + for htlc in &item.htlcs { + if htlc.route.is_none() { + continue; // no route found + } + + let route = htlc.route.clone().unwrap(); + let last_idx = route.hops.len() - 1; + let hop = route.hops[last_idx].clone(); + + pay.destination = hop.pub_key.clone(); + + for (key, value) in &hop.custom_records { + pay.custom_records.insert(*key, value.clone()); + } + } + + if let Some(val) = pay.custom_records.get(&lightning::TLV_PODCASTING20) { + pay.boostagram = Some(parse_podcast_tlv(&val)); + } + + return pay; + } +} + +#[async_trait] +impl LNClient for LNDClient { + + async fn get_info(&mut self) -> Result> { + let info = lnd::Lnd::get_info(&mut self.client).await?; + + Ok(NodeInfo { + pubkey: info.identity_pubkey, + alias: info.alias, + version: info.version, + nodetype: "LND".to_string(), + }) + } + + async fn channel_balance(&mut self) -> Result> { + let balance = lnd::Lnd::channel_balance(&mut self.client).await?; + let mut current_balance: i64 = 0; + + if let Some(bal) = balance.local_balance { + current_balance = bal.sat as i64; + } + + Ok(current_balance) + } + + async fn list_invoices(&mut self, start: u64, limit: u64) -> Result, Box> { + let result = match lnd::Lnd::list_invoices(&mut self.client, false, start, limit, false).await { + Ok(inv) => inv, + Err(_) => { + return Err("unable to fetch invoices".into()); + } + }; + + let mut invoices: Vec = Vec::new(); + + for item in result.invoices { + let payment_hash = HEXLOWER.encode(&item.r_hash); + let preimage = HEXLOWER.encode(&item.r_preimage); + + let mut inv = Invoice { + index: item.add_index, + time: item.settle_date, + amount: item.amt_paid_sat, + payment_hash: payment_hash, + preimage: preimage, + custom_records: HashMap::new(), + boostagram: None, + }; + + for htlc in item.htlcs { + for (key, value) in &htlc.custom_records { + inv.custom_records.insert(*key, value.clone()); + } + } + + if let Some(val) = inv.custom_records.get(&lightning::TLV_PODCASTING20) { + inv.boostagram = Some(parse_podcast_tlv(&val)); + } + + invoices.push(inv); + } + + + Ok(invoices) + } + + async fn list_payments(&mut self, start: u64, limit: u64) -> Result, Box> { + let result = match lnd::Lnd::list_payments(&mut self.client, false, start, limit, false).await { + Ok(inv) => inv, + Err(_) => { + return Err("unable to fetch payments".into()); + } + }; + + let mut payments: Vec = Vec::new(); + + for item in result.payments { + payments.push(self.parse_payment(&item)); + } + + Ok(payments) + } + + async fn keysend(&mut self, destination: String, sats: u64, custom_records: HashMap>) -> Result> { // -> Result> { + // convert pub key hash to raw bytes + let raw_pubkey = HEXLOWER.decode(destination.as_bytes()).unwrap(); + + // generate 32 random bytes for pre_image + let mut pre_image = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut pre_image); + + // and convert to sha256 hash + let mut hasher = Sha256::new(); + hasher.update(pre_image); + let payment_hash = hasher.finalize(); + + // add pre_image to custom_record for keysend + let mut custom_records = custom_records.clone(); + custom_records.insert(lightning::TLV_KEYSEND, pre_image.to_vec()); + + // assemble the lnd payment + let req = SendRequest { + dest: raw_pubkey.clone(), + amt: sats as i64, + payment_hash: payment_hash.to_vec(), + dest_custom_records: custom_records, + ..Default::default() + }; + + // send payment and get payment hash + let response = lnd::Lnd::send_payment_sync(&mut self.client, req).await?; + let sent_payment_hash = HEXLOWER.encode(&response.payment_hash); + + if response.payment_error != "" { + return Err(response.payment_error.into()); + } + + // get detailed payment info from list_payments + let payment_list = lnd::Lnd::list_payments(&mut self.client, false, 0, 500, true).await?; + + for payment in payment_list.payments { + if sent_payment_hash == payment.payment_hash { + return Ok(self.parse_payment(&payment)); + } + } + + Err("Failed to find payment sent".into()) + } +} diff --git a/src/main.rs b/src/main.rs index ddb8cdb..abdf707 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ extern crate configure_me; mod handler; mod lightning; mod podcastindex; +mod lnclient; const HELIPAD_CONFIG_FILE: &str = "./helipad.conf"; const HELIPAD_DATABASE_DIR: &str = "database.db"; @@ -40,6 +41,9 @@ const LND_STANDARD_GRPC_URL: &str = "https://127.0.0.1:10009"; const LND_STANDARD_MACAROON_LOCATION: &str = "/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; const LND_STANDARD_TLSCERT_LOCATION: &str = "/lnd/tls.cert"; +const CLN_REST_URL: &str = "https://127.0.0.1:3001"; +const CLN_REST_RUNE_PATH: &str = "/cln/commando_rune"; + const REMOTE_GUID_CACHE_SIZE: usize = 20; const WEBROOT_PATH_IMAGE: &str = "webroot/image"; @@ -59,11 +63,13 @@ pub struct HelipadConfig { pub database_file_path: String, pub sound_path: String, pub listen_port: String, - pub macaroon_path: String, - pub cert_path: String, - pub node_address: String, pub password: String, pub secret: String, + pub lnd_url: String, + pub lnd_macaroon_path: String, + pub lnd_cert_path: String, + pub cln_rest_url: String, + pub cln_rest_rune_path: String, } //Configure_me @@ -83,11 +89,13 @@ async fn main() { database_file_path: "".to_string(), sound_path: "".to_string(), listen_port: "".to_string(), - macaroon_path: "".to_string(), - cert_path: "".to_string(), - node_address: "".to_string(), password: "".to_string(), secret: "".to_string(), + lnd_url: "".to_string(), + lnd_macaroon_path: "".to_string(), + lnd_cert_path: "".to_string(), + cln_rest_url: "".to_string(), + cln_rest_rune_path: "".to_string(), }; //Bring in the configuration info @@ -194,54 +202,94 @@ async fn main() { .collect(); } - //Get the macaroon and cert files. Look in the local directory first as an override. - //If the files are not found in the currect working directory, look for them at their - //normal LND directory locations - println!("\nDiscovering macaroon file path..."); - let env_macaroon_path = std::env::var("LND_ADMINMACAROON"); - //First try from the environment - if env_macaroon_path.is_ok() { - helipad_config.macaroon_path = env_macaroon_path.unwrap(); - println!(" - Trying environment var(LND_ADMINMACAROON): [{}]", helipad_config.macaroon_path); - } else if server_config.macaroon.is_some() { - helipad_config.macaroon_path = server_config.macaroon.unwrap(); - println!(" - Trying config file({}): [{}]", HELIPAD_CONFIG_FILE, helipad_config.macaroon_path); - } else if Path::new("admin.macaroon").is_file() { - helipad_config.macaroon_path = "admin.macaroon".to_string(); - println!(" - Trying current directory: [{}]", helipad_config.macaroon_path); - } else { - helipad_config.macaroon_path = String::from(LND_STANDARD_MACAROON_LOCATION); - println!(" - Trying LND default: [{}]", helipad_config.macaroon_path); - } + //Get the url connection string of the CLN node if provided + let env_cln_rest_url = std::env::var("CLN_REST_URL"); - println!("\nDiscovering certificate file path..."); - let env_cert_path = std::env::var("LND_TLSCERT"); - if env_cert_path.is_ok() { - helipad_config.cert_path = env_cert_path.unwrap(); - println!(" - Trying environment var(LND_TLSCERT): [{}]", helipad_config.cert_path); - } else if server_config.cert.is_some() { - helipad_config.cert_path = server_config.cert.unwrap(); - println!(" - Trying config file({}): [{}]", HELIPAD_CONFIG_FILE, helipad_config.cert_path); - } else if Path::new("tls.cert").is_file() { - helipad_config.cert_path = "tls.cert".to_string(); - println!(" - Trying current directory: [{}]", helipad_config.cert_path); - } else { - helipad_config.cert_path = String::from(LND_STANDARD_TLSCERT_LOCATION); - println!(" - Trying LND default: [{}]", helipad_config.cert_path); + if env_cln_rest_url.is_ok() { + println!("\nDiscovering CLN REST URL..."); + let env_cln_rest_url = std::env::var("CLN_REST_URL"); + + if env_cln_rest_url.is_ok() { + helipad_config.cln_rest_url = "https://".to_owned() + env_cln_rest_url.unwrap().as_str(); + println!(" - Trying environment var(CLN_REST_URL): [{}]", helipad_config.cln_rest_url); + } else if server_config.cln_rest_url.is_some() { + helipad_config.cln_rest_url = server_config.cln_rest_url.unwrap(); + println!(" - Trying config file({}): [{}]", HELIPAD_CONFIG_FILE, helipad_config.cln_rest_url); + } else { + helipad_config.cln_rest_url = String::from(CLN_REST_URL); + println!(" - Trying localhost default: [{}].", helipad_config.cln_rest_url); + } + + //Get the rune string of the CLN node + if helipad_config.cln_rest_url != "" { + println!("\nDiscovering CLN REST Rune..."); + let env_cln_rest_rune_path = std::env::var("CLN_REST_RUNE_PATH"); + + if env_cln_rest_rune_path.is_ok() { + helipad_config.cln_rest_rune_path = env_cln_rest_rune_path.unwrap(); + println!(" - Trying environment var(CLN_REST_RUNE_PATH): [{}]", helipad_config.cln_rest_rune_path); + } else if server_config.cln_rest_rune_path.is_some() { + helipad_config.cln_rest_rune_path = server_config.cln_rest_rune_path.unwrap(); + println!(" - Trying config file({}): [{}]", HELIPAD_CONFIG_FILE, helipad_config.cln_rest_rune_path); + } else { + helipad_config.cln_rest_rune_path = String::from(CLN_REST_RUNE_PATH); + println!(" - Trying default: [{}]", helipad_config.cln_rest_rune_path); + } + } } + else { + //Get the url connection string of the lnd node + println!("\nDiscovering LND node address..."); + let env_lnd_url = std::env::var("LND_URL"); + + if env_lnd_url.is_ok() { + helipad_config.lnd_url = "https://".to_owned() + env_lnd_url.unwrap().as_str(); + println!(" - Trying environment var(LND_URL): [{}]", helipad_config.lnd_url); + } else if server_config.lnd_url.is_some() { + helipad_config.lnd_url = server_config.lnd_url.unwrap(); + println!(" - Trying config file({}): [{}]", HELIPAD_CONFIG_FILE, helipad_config.lnd_url); + } else { + helipad_config.lnd_url = String::from(LND_STANDARD_GRPC_URL); + println!(" - Trying localhost default: [{}].", helipad_config.lnd_url); + } - //Get the url connection string of the lnd node - println!("\nDiscovering LND node address..."); - let env_lnd_url = std::env::var("LND_URL"); - if env_lnd_url.is_ok() { - helipad_config.node_address = "https://".to_owned() + env_lnd_url.unwrap().as_str(); - println!(" - Trying environment var(LND_URL): [{}]", helipad_config.node_address); - } else if server_config.lnd_url.is_some() { - helipad_config.node_address = server_config.lnd_url.unwrap(); - println!(" - Trying config file({}): [{}]", HELIPAD_CONFIG_FILE, helipad_config.node_address); - } else { - helipad_config.node_address = String::from(LND_STANDARD_GRPC_URL); - println!(" - Trying localhost default: [{}].", helipad_config.node_address); + //Get the macaroon and cert files. Look in the local directory first as an override. + //If the files are not found in the currect working directory, look for them at their + //normal LND directory locations + println!("\nDiscovering macaroon file path..."); + let env_macaroon_path = std::env::var("LND_ADMINMACAROON"); + + //First try from the environment + if env_macaroon_path.is_ok() { + helipad_config.lnd_macaroon_path = env_macaroon_path.unwrap(); + println!(" - Trying environment var(LND_ADMINMACAROON): [{}]", helipad_config.lnd_macaroon_path); + } else if server_config.macaroon.is_some() { + helipad_config.lnd_macaroon_path = server_config.macaroon.unwrap(); + println!(" - Trying config file({}): [{}]", HELIPAD_CONFIG_FILE, helipad_config.lnd_macaroon_path); + } else if Path::new("admin.macaroon").is_file() { + helipad_config.lnd_macaroon_path = "admin.macaroon".to_string(); + println!(" - Trying current directory: [{}]", helipad_config.lnd_macaroon_path); + } else { + helipad_config.lnd_macaroon_path = String::from(LND_STANDARD_MACAROON_LOCATION); + println!(" - Trying LND default: [{}]", helipad_config.lnd_macaroon_path); + } + + println!("\nDiscovering certificate file path..."); + let env_cert_path = std::env::var("LND_TLSCERT"); + + if env_cert_path.is_ok() { + helipad_config.lnd_cert_path = env_cert_path.unwrap(); + println!(" - Trying environment var(LND_TLSCERT): [{}]", helipad_config.lnd_cert_path); + } else if server_config.cert.is_some() { + helipad_config.lnd_cert_path = server_config.cert.unwrap(); + println!(" - Trying config file({}): [{}]", HELIPAD_CONFIG_FILE, helipad_config.lnd_cert_path); + } else if Path::new("tls.cert").is_file() { + helipad_config.lnd_cert_path = "tls.cert".to_string(); + println!(" - Trying current directory: [{}]", helipad_config.lnd_cert_path); + } else { + helipad_config.lnd_cert_path = String::from(LND_STANDARD_TLSCERT_LOCATION); + println!(" - Trying LND default: [{}]", helipad_config.lnd_cert_path); + } } //Start the LND polling thread. This thread will poll LND every few seconds to @@ -353,26 +401,26 @@ async fn lnd_poller(helipad_config: HelipadConfig) { let db_filepath = helipad_config.database_file_path.clone(); //Make the connection to LND - println!("\nConnecting to LND node address..."); - let mut lightning; - match lightning::connect_to_lnd(helipad_config.node_address, helipad_config.cert_path, helipad_config.macaroon_path).await { - Some(lndconn) => { - println!(" - Success."); - lightning = lndconn; - } - None => { - std::process::exit(1); + println!("\nConnecting to node address..."); + + let mut lightning = match lnclient::connect(&helipad_config).await { + Ok(conn) => conn, + Err(e) => { + eprintln!("Unable to connect to node: {}", e); + return; } - } + }; + + println!(" - Success."); //Get lnd node info - match lnd::Lnd::get_info(&mut lightning).await { + match lightning.get_info().await { Ok(node_info) => { - println!("LND node info: {:#?}", node_info); + println!("Node info: {:#?}", node_info); let record = dbif::NodeInfoRecord { lnd_alias: node_info.alias, - node_pubkey: node_info.identity_pubkey, + node_pubkey: node_info.pubkey, node_version: node_info.version, }; @@ -381,7 +429,7 @@ async fn lnd_poller(helipad_config: HelipadConfig) { } } Err(e) => { - eprintln!("Error getting LND node info: {:#?}", e); + eprintln!("Error getting node info: {:#?}", e); } } @@ -396,82 +444,76 @@ async fn lnd_poller(helipad_config: HelipadConfig) { let mut updated = false; //Get lnd node channel balance - match lnd::Lnd::channel_balance(&mut lightning).await { - Ok(balance) => { - let mut current_balance: i64 = 0; - if let Some(bal) = balance.local_balance { - println!("LND node local balance: {:#?}", bal.sat); - current_balance = bal.sat as i64; - } - + match lightning.channel_balance().await { + Ok(current_balance) => { if dbif::add_wallet_balance_to_db(&db_filepath, current_balance).is_err() { println!("Error adding wallet balance to the database."); } - } + }, Err(e) => { - eprintln!("Error getting LND wallet balance: {:#?}", e); + eprintln!("Error getting wallet balance: {:#?}", e); } - } + }; //Get a list of invoices - match lnd::Lnd::list_invoices(&mut lightning, false, current_index.clone(), 500, false).await { - Ok(response) => { - for invoice in response.invoices { - let parsed = lightning::parse_boost_from_invoice(invoice.clone(), &mut remote_cache).await; - - if let Some(boost) = parsed { - //Give some output - println!("Boost: {:#?}", &boost); - - //Store in the database - match dbif::add_invoice_to_db(&db_filepath, &boost) { - Ok(_) => println!("New invoice added."), - Err(e) => eprintln!("Error adding invoice: {:#?}", e) - } - - //Send out webhooks (if any) - send_webhooks(&db_filepath, &boost).await; - } - - current_index = invoice.add_index; - updated = true; - } - } + let invoices = match lightning.list_invoices(current_index.clone(), 500).await { + Ok(invoices) => invoices, Err(e) => { - eprintln!("lnd::Lnd::list_invoices failed: {}", e); + eprintln!("lightning::list_invoices failed: {}", e); + vec![] } + }; + + for invoice in invoices { + if let Some(db_boost) = lightning::parse_boost_from_invoice(invoice.clone(), &mut remote_cache).await { + //Give some output + println!("Boost: {:#?}", &db_boost); + + //Store in the database + match dbif::add_invoice_to_db(&db_filepath, &db_boost) { + Ok(_) => println!("New invoice added."), + Err(e) => eprintln!("Error adding invoice: {:#?}", e) + } + + //Send out webhooks (if any) + send_webhooks(&db_filepath, &db_boost).await; + } + + current_index = invoice.index; + updated = true; } //Make sure we are tracking our position properly println!("Current index: {}", current_index); - match lnd::Lnd::list_payments(&mut lightning, false, current_payment, 500, false).await { - Ok(response) => { - for payment in response.payments { - let parsed = lightning::parse_boost_from_payment(payment.clone(), &mut remote_cache).await; - - if let Some(boost) = parsed { - //Give some output - println!("Sent Boost: {:#?}", boost); + let payments = match lightning.list_payments(current_payment, 500).await { + Ok(payments) => payments, + Err(e) => { + eprintln!("lightning::list_payments failed: {}", e); + vec![] + } + }; - //Store in the database - match dbif::add_payment_to_db(&db_filepath, &boost) { - Ok(_) => println!("New payment added."), - Err(e) => eprintln!("Error adding payment: {:#?}", e) - } + for payment in payments { + let parsed = lightning::parse_boost_from_payment(payment.clone(), &mut remote_cache).await; - //Send out webhooks (if any) - send_webhooks(&db_filepath, &boost).await; - } + if let Some(boost) = parsed { + //Give some output + println!("Sent Boost: {:#?}", boost); - current_payment = payment.payment_index; - updated = true; + //Store in the database + match dbif::add_payment_to_db(&db_filepath, &boost) { + Ok(_) => println!("New payment added."), + Err(e) => eprintln!("Error adding payment: {:#?}", e) } + + //Send out webhooks (if any) + send_webhooks(&db_filepath, &boost).await; } - Err(e) => { - eprintln!("lnd::Lnd::list_payments failed: {}", e); - } - }; + + current_payment = payment.index; + updated = true; + } //Make sure we are tracking our position properly println!("Current payment: {}", current_payment);