From 5a2189062560709b641bb13bedaca2cd478b4403 Mon Sep 17 00:00:00 2001 From: Joseph Goulden Date: Wed, 3 Jul 2024 10:22:59 +0100 Subject: [PATCH] feat: add cln payment processor --- .gitignore | 1 + Cargo.lock | 77 +++++++++++++++++++++- Cargo.toml | 1 + config.toml | 8 ++- src/config.rs | 24 +++++-- src/error.rs | 2 +- src/payment/cln_rest.rs | 137 ++++++++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 6 +- src/server.rs | 21 ++++-- 9 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 src/payment/cln_rest.rs diff --git a/.gitignore b/.gitignore index d8e965e..f0c0b13 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ nostr.db nostr.db-* justfile +result diff --git a/Cargo.lock b/Cargo.lock index efc0d4b..3c12169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -438,6 +438,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1945a5048598e4189e239d3f809b19bdad4845c4b2ba400d304d2dcf26d2c462" +dependencies = [ + "bech32", + "bitcoin-private", + "bitcoin_hashes 0.12.0", + "hex_lit", + "secp256k1 0.27.0", + "serde", +] + +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + [[package]] name = "bitcoin_hashes" version = "0.10.0" @@ -456,6 +476,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" +dependencies = [ + "bitcoin-private", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -611,6 +641,24 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +[[package]] +name = "cln-rpc" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974dac6f40275b7b828087f4f9973c39658f9b4a46cc589c083a2c6c27cf67cb" +dependencies = [ + "anyhow", + "bitcoin 0.30.2", + "bytes", + "futures-util", + "hex", + "log", + "serde", + "serde_json", + "tokio", + "tokio-util", +] + [[package]] name = "cloudabi" version = "0.0.3" @@ -1323,6 +1371,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hkdf" version = "0.12.4" @@ -1773,7 +1827,7 @@ checksum = "35c0446103768cddfb2bc1b87a52e98c35227b82711c2b3ce7098f8d85d9b0ee" dependencies = [ "aes", "base64 0.21.7", - "bitcoin", + "bitcoin 0.29.2", "cbc", "getrandom", "instant", @@ -1797,6 +1851,7 @@ dependencies = [ "bitcoin_hashes 0.10.0", "chrono", "clap", + "cln-rpc", "config", "console-subscriber", "const_format", @@ -2820,6 +2875,17 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" +dependencies = [ + "bitcoin_hashes 0.12.0", + "secp256k1-sys 0.8.1", + "serde", +] + [[package]] name = "secp256k1-sys" version = "0.4.2" @@ -2838,6 +2904,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a129b9e9efbfb223753b9163c4ab3b13cff7fd9c7f010fbac25ab4099fa07e" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "2.9.2" diff --git a/Cargo.toml b/Cargo.toml index 9415826..b6549e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ nostr = { version = "0.18.0", default-features = false, features = ["base", "nip log = "0.4" [target.'cfg(all(not(target_env = "msvc"), not(target_os = "openbsd")))'.dependencies] tikv-jemallocator = "0.5" +cln-rpc = "0.1.9" [dev-dependencies] anyhow = "1" diff --git a/config.toml b/config.toml index 4628775..1b67939 100644 --- a/config.toml +++ b/config.toml @@ -203,18 +203,24 @@ limit_scrapers = false # Enable pay to relay #enabled = false +# Node interface to use +#processor = "ClnRest/LNBits" + # The cost to be admitted to relay #admission_cost = 4200 # The cost in sats per post #cost_per_event = 0 -# Url of lnbits api +# Url of node api #node_url = "" # LNBits api secret #api_secret = "" +# Path to CLN rune +#rune_path = "" + # Nostr direct message on signup #direct_message=false diff --git a/src/config.rs b/src/config.rs index aa8bfb5..71a6014 100644 --- a/src/config.rs +++ b/src/config.rs @@ -98,6 +98,7 @@ pub struct PayToRelay { pub direct_message: bool, // Send direct message to user with invoice and terms pub secret_key: Option, pub processor: Processor, + pub rune_path: Option, // To access clightning API } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -247,17 +248,25 @@ impl Settings { // Validate pay to relay settings if settings.pay_to_relay.enabled { - assert_ne!(settings.pay_to_relay.api_secret, ""); + if settings.pay_to_relay.processor == Processor::ClnRest { + assert!(settings + .pay_to_relay + .rune_path + .as_ref() + .is_some_and(|path| path != "")); + } else if settings.pay_to_relay.processor == Processor::LNBits { + assert_ne!(settings.pay_to_relay.api_secret, ""); + } // Should check that url is valid assert_ne!(settings.pay_to_relay.node_url, ""); assert_ne!(settings.pay_to_relay.terms_message, ""); if settings.pay_to_relay.direct_message { - assert_ne!( - settings.pay_to_relay.secret_key, - Some("".to_string()) - ); - assert!(settings.pay_to_relay.secret_key.is_some()); + assert!(settings + .pay_to_relay + .secret_key + .as_ref() + .is_some_and(|key| key != "")); } } @@ -309,7 +318,7 @@ impl Default for Settings { event_persist_buffer: 4096, event_kind_blacklist: None, event_kind_allowlist: None, - limit_scrapers: false + limit_scrapers: false, }, authorization: Authorization { pubkey_whitelist: None, // Allow any address to publish @@ -323,6 +332,7 @@ impl Default for Settings { terms_message: "".to_string(), node_url: "".to_string(), api_secret: "".to_string(), + rune_path: None, sign_ups: false, direct_message: false, secret_key: None, diff --git a/src/error.rs b/src/error.rs index ecfa97f..9cd6968 100644 --- a/src/error.rs +++ b/src/error.rs @@ -42,7 +42,7 @@ pub enum Error { CommandUnknownError, #[error("SQL error")] SqlError(rusqlite::Error), - #[error("Config error")] + #[error("Config error : {0}")] ConfigError(config::ConfigError), #[error("Data directory does not exist")] DatabaseDirError, diff --git a/src/payment/cln_rest.rs b/src/payment/cln_rest.rs new file mode 100644 index 0000000..568f178 --- /dev/null +++ b/src/payment/cln_rest.rs @@ -0,0 +1,137 @@ +use std::{fs, str::FromStr}; + +use async_trait::async_trait; +use cln_rpc::{ + model::{ + requests::InvoiceRequest, + responses::{InvoiceResponse, ListinvoicesInvoicesStatus, ListinvoicesResponse}, + }, + primitives::{Amount, AmountOrAny}, +}; +use config::ConfigError; +use http::{header::CONTENT_TYPE, HeaderValue, Uri}; +use hyper::{client::HttpConnector, Client}; +use hyper_rustls::HttpsConnector; +use nostr::Keys; +use rand::random; + +use crate::{ + config::Settings, + error::{Error, Result}, +}; + +use super::{InvoiceInfo, InvoiceStatus, PaymentProcessor}; + +#[derive(Clone)] +pub struct ClnRestPaymentProcessor { + client: hyper::Client, hyper::Body>, + settings: Settings, + rune_header: HeaderValue, +} + +impl ClnRestPaymentProcessor { + pub fn new(settings: &Settings) -> Result { + let rune_path = settings + .pay_to_relay + .rune_path + .clone() + .ok_or(ConfigError::NotFound("rune_path".to_string()))?; + let rune = String::from_utf8(fs::read(rune_path)?) + .map_err(|_| ConfigError::Message("Rune should be UTF8".to_string()))?; + let mut rune_header = HeaderValue::from_str(&rune.trim()) + .map_err(|_| ConfigError::Message("Invalid Rune header".to_string()))?; + rune_header.set_sensitive(true); + + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots() + .https_only() + .enable_http1() + .build(); + let client = Client::builder().build::<_, hyper::Body>(https); + + Ok(Self { + client, + settings: settings.clone(), + rune_header, + }) + } +} + +#[async_trait] +impl PaymentProcessor for ClnRestPaymentProcessor { + async fn get_invoice(&self, key: &Keys, amount: u64) -> Result { + let random_number: u16 = random(); + let memo = format!("{}: {}", random_number, key.public_key()); + + let body = InvoiceRequest { + cltv: None, + deschashonly: None, + expiry: None, + preimage: None, + exposeprivatechannels: None, + fallbacks: None, + amount_msat: AmountOrAny::Amount(Amount::from_sat(amount)), + description: memo.clone(), + label: "Nostr".to_string(), + }; + let uri = Uri::from_str(&format!( + "{}/v1/invoice", + &self.settings.pay_to_relay.node_url + )) + .map_err(|_| ConfigError::Message("Bad node URL".to_string()))?; + + let req = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(uri) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header("Rune", self.rune_header.clone()) + .body(hyper::Body::from(serde_json::to_string(&body)?)) + .expect("request builder"); + + let res = self.client.request(req).await?; + + let body = hyper::body::to_bytes(res.into_body()).await?; + let invoice_response: InvoiceResponse = serde_json::from_slice(&body)?; + + Ok(InvoiceInfo { + pubkey: key.public_key().to_string(), + payment_hash: invoice_response.payment_hash.to_string(), + bolt11: invoice_response.bolt11, + amount, + memo, + status: InvoiceStatus::Unpaid, + confirmed_at: None, + }) + } + + async fn check_invoice(&self, payment_hash: &str) -> Result { + let uri = Uri::from_str(&format!( + "{}/v1/listinvoices?payment_hash={}", + &self.settings.pay_to_relay.node_url, payment_hash + )) + .map_err(|_| ConfigError::Message("Bad node URL".to_string()))?; + + let req = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(uri) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header("Rune", self.rune_header.clone()) + .body(hyper::Body::empty()) + .expect("request builder"); + + let res = self.client.request(req).await?; + + let body = hyper::body::to_bytes(res.into_body()).await?; + let invoice_response: ListinvoicesResponse = serde_json::from_slice(&body)?; + let invoice = invoice_response + .invoices + .first() + .ok_or(Error::CustomError("Invoice not found".to_string()))?; + let status = match invoice.status { + ListinvoicesInvoicesStatus::PAID => InvoiceStatus::Paid, + ListinvoicesInvoicesStatus::UNPAID => InvoiceStatus::Unpaid, + ListinvoicesInvoicesStatus::EXPIRED => InvoiceStatus::Expired, + }; + Ok(status) + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 0158cf8..9d59afe 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -1,5 +1,6 @@ use crate::error::{Error, Result}; use crate::event::Event; +use crate::payment::cln_rest::ClnRestPaymentProcessor; use crate::payment::lnbits::LNBitsPaymentProcessor; use crate::repo::NostrRepo; use serde::{Deserialize, Serialize}; @@ -10,6 +11,7 @@ use async_trait::async_trait; use nostr::key::{FromPkStr, FromSkStr}; use nostr::{key::Keys, Event as NostrEvent, EventBuilder}; +pub mod cln_rest; pub mod lnbits; /// Payment handler @@ -41,6 +43,7 @@ pub trait PaymentProcessor: Send + Sync { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum Processor { LNBits, + ClnRest, } /// Possible states of an invoice @@ -109,8 +112,9 @@ impl Payment { }; // Create processor kind defined in settings - let processor = match &settings.pay_to_relay.processor { + let processor: Arc = match &settings.pay_to_relay.processor { Processor::LNBits => Arc::new(LNBitsPaymentProcessor::new(&settings)), + Processor::ClnRest => Arc::new(ClnRestPaymentProcessor::new(&settings)?), }; Ok(Payment { diff --git a/src/server.rs b/src/server.rs index dcf9580..4a8d87d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -568,6 +568,11 @@ async fn handle_web_request( .unwrap()); } + // Account is checked async so user will have to refresh the page a couple times after + // they have paid. + if let Err(e) = payment_tx.send(PaymentMessage::CheckAccount(pubkey.clone())) { + warn!("Could not check account: {}", e); + } // Checks if user is already admitted let text = if let Ok((admission_status, _)) = repo.get_account_balance(&key.unwrap()).await { @@ -894,11 +899,17 @@ pub fn start_server(settings: &Settings, shutdown_rx: MpscReceiver<()>) -> Resul bcast_tx.clone(), settings.clone(), ); - if let Ok(mut p) = payment_opt { - tokio::task::spawn(async move { - info!("starting payment process ..."); - p.run().await; - }); + match payment_opt { + Ok(mut p) => { + tokio::task::spawn(async move { + info!("starting payment process ..."); + p.run().await; + }); + }, + Err(e) => { + error!("Failed to start payment process {e}"); + std::process::exit(1); + } } }