Skip to content

Commit 5a21890

Browse files
Joseph Gouldenscsibug
authored andcommitted
feat: add cln payment processor
1 parent 0d04b5e commit 5a21890

9 files changed

Lines changed: 261 additions & 16 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
nostr.db
33
nostr.db-*
44
justfile
5+
result

Cargo.lock

Lines changed: 76 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ nostr = { version = "0.18.0", default-features = false, features = ["base", "nip
5858
log = "0.4"
5959
[target.'cfg(all(not(target_env = "msvc"), not(target_os = "openbsd")))'.dependencies]
6060
tikv-jemallocator = "0.5"
61+
cln-rpc = "0.1.9"
6162

6263
[dev-dependencies]
6364
anyhow = "1"

config.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,18 +203,24 @@ limit_scrapers = false
203203
# Enable pay to relay
204204
#enabled = false
205205

206+
# Node interface to use
207+
#processor = "ClnRest/LNBits"
208+
206209
# The cost to be admitted to relay
207210
#admission_cost = 4200
208211

209212
# The cost in sats per post
210213
#cost_per_event = 0
211214

212-
# Url of lnbits api
215+
# Url of node api
213216
#node_url = "<node url>"
214217

215218
# LNBits api secret
216219
#api_secret = "<ln bits api>"
217220

221+
# Path to CLN rune
222+
#rune_path = "<rune path>"
223+
218224
# Nostr direct message on signup
219225
#direct_message=false
220226

src/config.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ pub struct PayToRelay {
9898
pub direct_message: bool, // Send direct message to user with invoice and terms
9999
pub secret_key: Option<String>,
100100
pub processor: Processor,
101+
pub rune_path: Option<String>, // To access clightning API
101102
}
102103

103104
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -247,17 +248,25 @@ impl Settings {
247248

248249
// Validate pay to relay settings
249250
if settings.pay_to_relay.enabled {
250-
assert_ne!(settings.pay_to_relay.api_secret, "");
251+
if settings.pay_to_relay.processor == Processor::ClnRest {
252+
assert!(settings
253+
.pay_to_relay
254+
.rune_path
255+
.as_ref()
256+
.is_some_and(|path| path != "<rune path>"));
257+
} else if settings.pay_to_relay.processor == Processor::LNBits {
258+
assert_ne!(settings.pay_to_relay.api_secret, "");
259+
}
251260
// Should check that url is valid
252261
assert_ne!(settings.pay_to_relay.node_url, "");
253262
assert_ne!(settings.pay_to_relay.terms_message, "");
254263

255264
if settings.pay_to_relay.direct_message {
256-
assert_ne!(
257-
settings.pay_to_relay.secret_key,
258-
Some("<nostr nsec>".to_string())
259-
);
260-
assert!(settings.pay_to_relay.secret_key.is_some());
265+
assert!(settings
266+
.pay_to_relay
267+
.secret_key
268+
.as_ref()
269+
.is_some_and(|key| key != "<nostr nsec>"));
261270
}
262271
}
263272

@@ -309,7 +318,7 @@ impl Default for Settings {
309318
event_persist_buffer: 4096,
310319
event_kind_blacklist: None,
311320
event_kind_allowlist: None,
312-
limit_scrapers: false
321+
limit_scrapers: false,
313322
},
314323
authorization: Authorization {
315324
pubkey_whitelist: None, // Allow any address to publish
@@ -323,6 +332,7 @@ impl Default for Settings {
323332
terms_message: "".to_string(),
324333
node_url: "".to_string(),
325334
api_secret: "".to_string(),
335+
rune_path: None,
326336
sign_ups: false,
327337
direct_message: false,
328338
secret_key: None,

src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub enum Error {
4242
CommandUnknownError,
4343
#[error("SQL error")]
4444
SqlError(rusqlite::Error),
45-
#[error("Config error")]
45+
#[error("Config error : {0}")]
4646
ConfigError(config::ConfigError),
4747
#[error("Data directory does not exist")]
4848
DatabaseDirError,

src/payment/cln_rest.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
use std::{fs, str::FromStr};
2+
3+
use async_trait::async_trait;
4+
use cln_rpc::{
5+
model::{
6+
requests::InvoiceRequest,
7+
responses::{InvoiceResponse, ListinvoicesInvoicesStatus, ListinvoicesResponse},
8+
},
9+
primitives::{Amount, AmountOrAny},
10+
};
11+
use config::ConfigError;
12+
use http::{header::CONTENT_TYPE, HeaderValue, Uri};
13+
use hyper::{client::HttpConnector, Client};
14+
use hyper_rustls::HttpsConnector;
15+
use nostr::Keys;
16+
use rand::random;
17+
18+
use crate::{
19+
config::Settings,
20+
error::{Error, Result},
21+
};
22+
23+
use super::{InvoiceInfo, InvoiceStatus, PaymentProcessor};
24+
25+
#[derive(Clone)]
26+
pub struct ClnRestPaymentProcessor {
27+
client: hyper::Client<HttpsConnector<HttpConnector>, hyper::Body>,
28+
settings: Settings,
29+
rune_header: HeaderValue,
30+
}
31+
32+
impl ClnRestPaymentProcessor {
33+
pub fn new(settings: &Settings) -> Result<Self> {
34+
let rune_path = settings
35+
.pay_to_relay
36+
.rune_path
37+
.clone()
38+
.ok_or(ConfigError::NotFound("rune_path".to_string()))?;
39+
let rune = String::from_utf8(fs::read(rune_path)?)
40+
.map_err(|_| ConfigError::Message("Rune should be UTF8".to_string()))?;
41+
let mut rune_header = HeaderValue::from_str(&rune.trim())
42+
.map_err(|_| ConfigError::Message("Invalid Rune header".to_string()))?;
43+
rune_header.set_sensitive(true);
44+
45+
let https = hyper_rustls::HttpsConnectorBuilder::new()
46+
.with_native_roots()
47+
.https_only()
48+
.enable_http1()
49+
.build();
50+
let client = Client::builder().build::<_, hyper::Body>(https);
51+
52+
Ok(Self {
53+
client,
54+
settings: settings.clone(),
55+
rune_header,
56+
})
57+
}
58+
}
59+
60+
#[async_trait]
61+
impl PaymentProcessor for ClnRestPaymentProcessor {
62+
async fn get_invoice(&self, key: &Keys, amount: u64) -> Result<InvoiceInfo, Error> {
63+
let random_number: u16 = random();
64+
let memo = format!("{}: {}", random_number, key.public_key());
65+
66+
let body = InvoiceRequest {
67+
cltv: None,
68+
deschashonly: None,
69+
expiry: None,
70+
preimage: None,
71+
exposeprivatechannels: None,
72+
fallbacks: None,
73+
amount_msat: AmountOrAny::Amount(Amount::from_sat(amount)),
74+
description: memo.clone(),
75+
label: "Nostr".to_string(),
76+
};
77+
let uri = Uri::from_str(&format!(
78+
"{}/v1/invoice",
79+
&self.settings.pay_to_relay.node_url
80+
))
81+
.map_err(|_| ConfigError::Message("Bad node URL".to_string()))?;
82+
83+
let req = hyper::Request::builder()
84+
.method(hyper::Method::POST)
85+
.uri(uri)
86+
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
87+
.header("Rune", self.rune_header.clone())
88+
.body(hyper::Body::from(serde_json::to_string(&body)?))
89+
.expect("request builder");
90+
91+
let res = self.client.request(req).await?;
92+
93+
let body = hyper::body::to_bytes(res.into_body()).await?;
94+
let invoice_response: InvoiceResponse = serde_json::from_slice(&body)?;
95+
96+
Ok(InvoiceInfo {
97+
pubkey: key.public_key().to_string(),
98+
payment_hash: invoice_response.payment_hash.to_string(),
99+
bolt11: invoice_response.bolt11,
100+
amount,
101+
memo,
102+
status: InvoiceStatus::Unpaid,
103+
confirmed_at: None,
104+
})
105+
}
106+
107+
async fn check_invoice(&self, payment_hash: &str) -> Result<InvoiceStatus, Error> {
108+
let uri = Uri::from_str(&format!(
109+
"{}/v1/listinvoices?payment_hash={}",
110+
&self.settings.pay_to_relay.node_url, payment_hash
111+
))
112+
.map_err(|_| ConfigError::Message("Bad node URL".to_string()))?;
113+
114+
let req = hyper::Request::builder()
115+
.method(hyper::Method::POST)
116+
.uri(uri)
117+
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
118+
.header("Rune", self.rune_header.clone())
119+
.body(hyper::Body::empty())
120+
.expect("request builder");
121+
122+
let res = self.client.request(req).await?;
123+
124+
let body = hyper::body::to_bytes(res.into_body()).await?;
125+
let invoice_response: ListinvoicesResponse = serde_json::from_slice(&body)?;
126+
let invoice = invoice_response
127+
.invoices
128+
.first()
129+
.ok_or(Error::CustomError("Invoice not found".to_string()))?;
130+
let status = match invoice.status {
131+
ListinvoicesInvoicesStatus::PAID => InvoiceStatus::Paid,
132+
ListinvoicesInvoicesStatus::UNPAID => InvoiceStatus::Unpaid,
133+
ListinvoicesInvoicesStatus::EXPIRED => InvoiceStatus::Expired,
134+
};
135+
Ok(status)
136+
}
137+
}

0 commit comments

Comments
 (0)