M .gitignore => .gitignore +1 -0
@@ 2,3 2,4 @@
nostr.db
nostr.db-*
justfile
+result
M Cargo.lock => Cargo.lock +76 -1
@@ 439,6 439,26 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 457,6 477,16 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 612,6 642,24 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 1324,6 1372,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 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",
@@ 2821,6 2876,17 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 2839,6 2905,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml => Cargo.toml +1 -0
@@ 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"
M config.toml => config.toml +7 -1
@@ 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 = "<node url>"
# LNBits api secret
#api_secret = "<ln bits api>"
+# Path to CLN rune
+#rune_path = "<rune path>"
+
# Nostr direct message on signup
#direct_message=false
M src/config.rs => src/config.rs +17 -7
@@ 98,6 98,7 @@ pub struct PayToRelay {
pub direct_message: bool, // Send direct message to user with invoice and terms
pub secret_key: Option<String>,
pub processor: Processor,
+ pub rune_path: Option<String>, // 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 != "<rune 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("<nostr nsec>".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 != "<nostr nsec>"));
}
}
@@ 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,
M src/error.rs => src/error.rs +1 -1
@@ 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,
A src/payment/cln_rest.rs => src/payment/cln_rest.rs +137 -0
@@ 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<HttpsConnector<HttpConnector>, hyper::Body>,
+ settings: Settings,
+ rune_header: HeaderValue,
+}
+
+impl ClnRestPaymentProcessor {
+ pub fn new(settings: &Settings) -> Result<Self> {
+ 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<InvoiceInfo, Error> {
+ 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<InvoiceStatus, Error> {
+ 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)
+ }
+}
M src/payment/mod.rs => src/payment/mod.rs +5 -1
@@ 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<dyn PaymentProcessor> = match &settings.pay_to_relay.processor {
Processor::LNBits => Arc::new(LNBitsPaymentProcessor::new(&settings)),
+ Processor::ClnRest => Arc::new(ClnRestPaymentProcessor::new(&settings)?),
};
Ok(Payment {
M src/server.rs => src/server.rs +16 -5
@@ 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);
+ }
}
}