feat: add cln payment processor

This commit is contained in:
Joseph Goulden 2024-07-03 10:22:59 +01:00 committed by Greg Heartsfield
parent 0d04b5eefa
commit 5a21890625
9 changed files with 261 additions and 16 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
nostr.db nostr.db
nostr.db-* nostr.db-*
justfile justfile
result

77
Cargo.lock generated
View File

@ -438,6 +438,26 @@ dependencies = [
"serde", "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]] [[package]]
name = "bitcoin_hashes" name = "bitcoin_hashes"
version = "0.10.0" version = "0.10.0"
@ -456,6 +476,16 @@ dependencies = [
"serde", "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]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -611,6 +641,24 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" 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]] [[package]]
name = "cloudabi" name = "cloudabi"
version = "0.0.3" version = "0.0.3"
@ -1323,6 +1371,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex_lit"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.12.4" version = "0.12.4"
@ -1773,7 +1827,7 @@ checksum = "35c0446103768cddfb2bc1b87a52e98c35227b82711c2b3ce7098f8d85d9b0ee"
dependencies = [ dependencies = [
"aes", "aes",
"base64 0.21.7", "base64 0.21.7",
"bitcoin", "bitcoin 0.29.2",
"cbc", "cbc",
"getrandom", "getrandom",
"instant", "instant",
@ -1797,6 +1851,7 @@ dependencies = [
"bitcoin_hashes 0.10.0", "bitcoin_hashes 0.10.0",
"chrono", "chrono",
"clap", "clap",
"cln-rpc",
"config", "config",
"console-subscriber", "console-subscriber",
"const_format", "const_format",
@ -2820,6 +2875,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "secp256k1-sys" name = "secp256k1-sys"
version = "0.4.2" version = "0.4.2"
@ -2838,6 +2904,15 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "secp256k1-sys"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70a129b9e9efbfb223753b9163c4ab3b13cff7fd9c7f010fbac25ab4099fa07e"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.9.2" version = "2.9.2"

View File

@ -58,6 +58,7 @@ nostr = { version = "0.18.0", default-features = false, features = ["base", "nip
log = "0.4" log = "0.4"
[target.'cfg(all(not(target_env = "msvc"), not(target_os = "openbsd")))'.dependencies] [target.'cfg(all(not(target_env = "msvc"), not(target_os = "openbsd")))'.dependencies]
tikv-jemallocator = "0.5" tikv-jemallocator = "0.5"
cln-rpc = "0.1.9"
[dev-dependencies] [dev-dependencies]
anyhow = "1" anyhow = "1"

View File

@ -203,18 +203,24 @@ limit_scrapers = false
# Enable pay to relay # Enable pay to relay
#enabled = false #enabled = false
# Node interface to use
#processor = "ClnRest/LNBits"
# The cost to be admitted to relay # The cost to be admitted to relay
#admission_cost = 4200 #admission_cost = 4200
# The cost in sats per post # The cost in sats per post
#cost_per_event = 0 #cost_per_event = 0
# Url of lnbits api # Url of node api
#node_url = "<node url>" #node_url = "<node url>"
# LNBits api secret # LNBits api secret
#api_secret = "<ln bits api>" #api_secret = "<ln bits api>"
# Path to CLN rune
#rune_path = "<rune path>"
# Nostr direct message on signup # Nostr direct message on signup
#direct_message=false #direct_message=false

View File

@ -98,6 +98,7 @@ pub struct PayToRelay {
pub direct_message: bool, // Send direct message to user with invoice and terms pub direct_message: bool, // Send direct message to user with invoice and terms
pub secret_key: Option<String>, pub secret_key: Option<String>,
pub processor: Processor, pub processor: Processor,
pub rune_path: Option<String>, // To access clightning API
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -247,17 +248,25 @@ impl Settings {
// Validate pay to relay settings // Validate pay to relay settings
if settings.pay_to_relay.enabled { 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 // Should check that url is valid
assert_ne!(settings.pay_to_relay.node_url, ""); assert_ne!(settings.pay_to_relay.node_url, "");
assert_ne!(settings.pay_to_relay.terms_message, ""); assert_ne!(settings.pay_to_relay.terms_message, "");
if settings.pay_to_relay.direct_message { if settings.pay_to_relay.direct_message {
assert_ne!( assert!(settings
settings.pay_to_relay.secret_key, .pay_to_relay
Some("<nostr nsec>".to_string()) .secret_key
); .as_ref()
assert!(settings.pay_to_relay.secret_key.is_some()); .is_some_and(|key| key != "<nostr nsec>"));
} }
} }
@ -309,7 +318,7 @@ impl Default for Settings {
event_persist_buffer: 4096, event_persist_buffer: 4096,
event_kind_blacklist: None, event_kind_blacklist: None,
event_kind_allowlist: None, event_kind_allowlist: None,
limit_scrapers: false limit_scrapers: false,
}, },
authorization: Authorization { authorization: Authorization {
pubkey_whitelist: None, // Allow any address to publish pubkey_whitelist: None, // Allow any address to publish
@ -323,6 +332,7 @@ impl Default for Settings {
terms_message: "".to_string(), terms_message: "".to_string(),
node_url: "".to_string(), node_url: "".to_string(),
api_secret: "".to_string(), api_secret: "".to_string(),
rune_path: None,
sign_ups: false, sign_ups: false,
direct_message: false, direct_message: false,
secret_key: None, secret_key: None,

View File

@ -42,7 +42,7 @@ pub enum Error {
CommandUnknownError, CommandUnknownError,
#[error("SQL error")] #[error("SQL error")]
SqlError(rusqlite::Error), SqlError(rusqlite::Error),
#[error("Config error")] #[error("Config error : {0}")]
ConfigError(config::ConfigError), ConfigError(config::ConfigError),
#[error("Data directory does not exist")] #[error("Data directory does not exist")]
DatabaseDirError, DatabaseDirError,

137
src/payment/cln_rest.rs Normal file
View File

@ -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)
}
}

View File

@ -1,5 +1,6 @@
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::event::Event; use crate::event::Event;
use crate::payment::cln_rest::ClnRestPaymentProcessor;
use crate::payment::lnbits::LNBitsPaymentProcessor; use crate::payment::lnbits::LNBitsPaymentProcessor;
use crate::repo::NostrRepo; use crate::repo::NostrRepo;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -10,6 +11,7 @@ use async_trait::async_trait;
use nostr::key::{FromPkStr, FromSkStr}; use nostr::key::{FromPkStr, FromSkStr};
use nostr::{key::Keys, Event as NostrEvent, EventBuilder}; use nostr::{key::Keys, Event as NostrEvent, EventBuilder};
pub mod cln_rest;
pub mod lnbits; pub mod lnbits;
/// Payment handler /// Payment handler
@ -41,6 +43,7 @@ pub trait PaymentProcessor: Send + Sync {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum Processor { pub enum Processor {
LNBits, LNBits,
ClnRest,
} }
/// Possible states of an invoice /// Possible states of an invoice
@ -109,8 +112,9 @@ impl Payment {
}; };
// Create processor kind defined in settings // 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::LNBits => Arc::new(LNBitsPaymentProcessor::new(&settings)),
Processor::ClnRest => Arc::new(ClnRestPaymentProcessor::new(&settings)?),
}; };
Ok(Payment { Ok(Payment {

View File

@ -568,6 +568,11 @@ async fn handle_web_request(
.unwrap()); .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 // Checks if user is already admitted
let text = let text =
if let Ok((admission_status, _)) = repo.get_account_balance(&key.unwrap()).await { 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(), bcast_tx.clone(),
settings.clone(), settings.clone(),
); );
if let Ok(mut p) = payment_opt { match payment_opt {
tokio::task::spawn(async move { Ok(mut p) => {
info!("starting payment process ..."); tokio::task::spawn(async move {
p.run().await; info!("starting payment process ...");
}); p.run().await;
});
},
Err(e) => {
error!("Failed to start payment process {e}");
std::process::exit(1);
}
} }
} }