~gheartsfield/nostr-rs-relay

5a2189062560709b641bb13bedaca2cd478b4403 — Joseph Goulden 2 months ago 0d04b5e
feat: add cln payment processor
9 files changed, 261 insertions(+), 16 deletions(-)

M .gitignore
M Cargo.lock
M Cargo.toml
M config.toml
M src/config.rs
M src/error.rs
A src/payment/cln_rest.rs
M src/payment/mod.rs
M src/server.rs
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);
                }
            }
        }