thesimplekid 9 месяцев назад
Родитель
Сommit
16aeec92c7

+ 6 - 7
Cargo.toml

@@ -22,24 +22,23 @@ keywords = ["bitcoin", "e-cash", "cashu"]
 
 [workspace.dependencies]
 async-trait = "0.1.74"
+anyhow = "1"
+bitcoin = { version = "0.30", default-features = false } # lightning-invoice uses v0.30
 bip39 = "2.0"
 cdk = { version = "0.1", path = "./crates/cdk", default-features = false }
 cdk-rexie = { version = "0.1", path = "./crates/cdk-rexie", default-features = false }
 cdk-sqlite = { version = "0.1", path = "./crates/cdk-sqlite", default-features = false }
 cdk-redb = { version = "0.1", path = "./crates/cdk-redb", default-features = false }
+cdk-cln = { version = "0.1", path = "./crates/cdk-cln", default-features = false }
 tokio = { version = "1", default-features = false }
 thiserror = "1"
 tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] }
 serde = { version = "1", default-features = false, features = ["derive"] }
 serde_json = "1"
-serde-wasm-bindgen = { version = "0.6.5", default-features = false }
+serde-wasm-bindgen = "0.6.5"
+futures = { version = "0.3.28", default-feature = false }
 web-sys =  { version = "0.3.69", default-features = false, features = ["console"] }
-bitcoin = { version = "0.30", features = [
-    "serde",
-    "rand",
-    "rand-std",
-] } # lightning-invoice uses v0.30
-anyhow = "1"
+uuid = { version = "1", features = ["v4"] }
 
 [profile]
 

+ 20 - 0
crates/cdk-cln/Cargo.toml

@@ -0,0 +1,20 @@
+[package]
+name = "cdk-cln"
+version = "0.1.0"
+edition = "2021"
+authors = ["CDK Developers"]
+homepage.workspace = true
+repository.workspace = true
+rust-version.workspace = true # MSRV
+license.workspace = true
+
+[dependencies]
+async-trait.workspace = true
+bitcoin.workspace = true
+cdk = { workspace = true, default-features = false, features = ["mint"] }
+cln-rpc = "0.1.9"
+futures.workspace = true
+tokio.workspace = true
+tracing.workspace = true
+thiserror.workspace = true
+uuid.workspace = true

+ 25 - 0
crates/cdk-cln/src/error.rs

@@ -0,0 +1,25 @@
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Wrong CLN response
+    #[error("Wrong cln response")]
+    WrongClnResponse,
+    /// Unknown invoice
+    #[error("Unknown invoice")]
+    UnknownInvoice,
+    /// Cln Error
+    #[error(transparent)]
+    Cln(#[from] cln_rpc::Error),
+    /// Cln Rpc Error
+    #[error(transparent)]
+    ClnRpc(#[from] cln_rpc::RpcError),
+    #[error("`{0}`")]
+    Custom(String),
+}
+
+impl From<Error> for cdk::cdk_lightning::Error {
+    fn from(e: Error) -> Self {
+        Self::Lightning(Box::new(e))
+    }
+}

+ 256 - 0
crates/cdk-cln/src/lib.rs

@@ -0,0 +1,256 @@
+//! CDK lightning backend for CLN
+
+use std::path::PathBuf;
+use std::pin::Pin;
+use std::str::FromStr;
+use std::sync::Arc;
+use std::time::Duration;
+
+use async_trait::async_trait;
+use cdk::cdk_lightning::{self, MintLightning, PayInvoiceResponse};
+use cdk::nuts::{MeltQuoteState, MintQuoteState};
+use cdk::util::{hex, unix_time};
+use cdk::Bolt11Invoice;
+use cln_rpc::model::requests::{
+    InvoiceRequest, ListinvoicesRequest, PayRequest, WaitanyinvoiceRequest,
+};
+use cln_rpc::model::responses::{ListinvoicesInvoicesStatus, PayStatus, WaitanyinvoiceResponse};
+use cln_rpc::model::Request;
+use cln_rpc::primitives::{Amount as CLN_Amount, AmountOrAny};
+use error::Error;
+use futures::{Stream, StreamExt};
+use tokio::sync::Mutex;
+use uuid::Uuid;
+
+pub mod error;
+
+#[derive(Clone)]
+pub struct Cln {
+    rpc_socket: PathBuf,
+    cln_client: Arc<Mutex<cln_rpc::ClnRpc>>,
+}
+
+impl Cln {
+    pub async fn new(rpc_socket: PathBuf) -> Result<Self, Error> {
+        let cln_client = cln_rpc::ClnRpc::new(&rpc_socket).await?;
+
+        Ok(Self {
+            rpc_socket,
+            cln_client: Arc::new(Mutex::new(cln_client)),
+        })
+    }
+}
+
+#[async_trait]
+impl MintLightning for Cln {
+    type Err = cdk_lightning::Error;
+
+    async fn wait_any_invoice(
+        &self,
+    ) -> Result<Pin<Box<dyn Stream<Item = Bolt11Invoice> + Send>>, Self::Err> {
+        let last_pay_index = self.get_last_pay_index().await?;
+        let cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?;
+
+        Ok(futures::stream::unfold(
+            (cln_client, last_pay_index),
+            |(mut cln_client, mut last_pay_idx)| async move {
+                loop {
+                    let invoice_res = cln_client
+                        .call(cln_rpc::Request::WaitAnyInvoice(WaitanyinvoiceRequest {
+                            timeout: None,
+                            lastpay_index: last_pay_idx,
+                        }))
+                        .await;
+
+                    let invoice: WaitanyinvoiceResponse = match invoice_res {
+                        Ok(invoice) => invoice,
+                        Err(e) => {
+                            tracing::warn!("Error fetching invoice: {e}");
+                            // Let's not spam CLN with requests on failure
+                            tokio::time::sleep(Duration::from_secs(1)).await;
+                            // Retry same request
+                            continue;
+                        }
+                    }
+                    .try_into()
+                    .expect("Wrong response from CLN");
+
+                    last_pay_idx = invoice.pay_index;
+
+                    if let Some(bolt11) = invoice.bolt11 {
+                        if let Ok(invoice) = Bolt11Invoice::from_str(&bolt11) {
+                            break Some((invoice, (cln_client, last_pay_idx)));
+                        }
+                    }
+                }
+            },
+        )
+        .boxed())
+    }
+
+    async fn pay_invoice(
+        &self,
+        bolt11: Bolt11Invoice,
+        partial_msats: Option<u64>,
+        max_fee_msats: Option<u64>,
+    ) -> Result<PayInvoiceResponse, Self::Err> {
+        let mut cln_client = self.cln_client.lock().await;
+        let cln_response = cln_client
+            .call(Request::Pay(PayRequest {
+                bolt11: bolt11.to_string(),
+                amount_msat: None,
+                label: None,
+                riskfactor: None,
+                maxfeepercent: None,
+                retry_for: None,
+                maxdelay: None,
+                exemptfee: None,
+                localinvreqid: None,
+                exclude: None,
+                maxfee: max_fee_msats.map(CLN_Amount::from_msat),
+                description: None,
+                partial_msat: partial_msats.map(CLN_Amount::from_msat),
+            }))
+            .await
+            .map_err(Error::from)?;
+
+        let response = match cln_response {
+            cln_rpc::Response::Pay(pay_response) => {
+                let status = match pay_response.status {
+                    PayStatus::COMPLETE => MeltQuoteState::Paid,
+                    PayStatus::PENDING => MeltQuoteState::Pending,
+                    PayStatus::FAILED => MeltQuoteState::Unpaid,
+                };
+                PayInvoiceResponse {
+                    payment_preimage: Some(hex::encode(pay_response.payment_preimage.to_vec())),
+                    payment_hash: pay_response.payment_hash.to_string(),
+                    status,
+                    total_spent_msats: pay_response.amount_sent_msat.msat(),
+                }
+            }
+            _ => {
+                tracing::warn!("CLN returned wrong response kind");
+                return Err(cdk_lightning::Error::from(Error::WrongClnResponse));
+            }
+        };
+
+        Ok(response)
+    }
+
+    async fn create_invoice(
+        &self,
+        amount_msats: u64,
+        description: String,
+        unix_expiry: u64,
+    ) -> Result<Bolt11Invoice, Self::Err> {
+        let time_now = unix_time();
+        assert!(unix_expiry > time_now);
+
+        let mut cln_client = self.cln_client.lock().await;
+
+        let amount_msat = AmountOrAny::Amount(CLN_Amount::from_msat(amount_msats));
+        let cln_response = cln_client
+            .call(cln_rpc::Request::Invoice(InvoiceRequest {
+                amount_msat,
+                description,
+                label: Uuid::new_v4().to_string(),
+                expiry: Some(unix_expiry - time_now),
+                fallbacks: None,
+                preimage: None,
+                cltv: None,
+                deschashonly: None,
+                exposeprivatechannels: None,
+            }))
+            .await
+            .map_err(Error::from)?;
+
+        let invoice = match cln_response {
+            cln_rpc::Response::Invoice(invoice_res) => {
+                Bolt11Invoice::from_str(&invoice_res.bolt11)?
+            }
+            _ => {
+                tracing::warn!("CLN returned wrong response kind");
+                return Err(Error::WrongClnResponse.into());
+            }
+        };
+
+        Ok(invoice)
+    }
+
+    async fn check_invoice_status(&self, payment_hash: &str) -> Result<MintQuoteState, Self::Err> {
+        let mut cln_client = self.cln_client.lock().await;
+
+        let cln_response = cln_client
+            .call(Request::ListInvoices(ListinvoicesRequest {
+                payment_hash: Some(payment_hash.to_string()),
+                label: None,
+                invstring: None,
+                offer_id: None,
+                index: None,
+                limit: None,
+                start: None,
+            }))
+            .await
+            .map_err(Error::from)?;
+
+        let status = match cln_response {
+            cln_rpc::Response::ListInvoices(invoice_response) => {
+                match invoice_response.invoices.first() {
+                    Some(invoice_response) => {
+                        cln_invoice_status_to_mint_state(invoice_response.status)
+                    }
+                    None => {
+                        tracing::info!(
+                            "Check invoice called on unknown payment_hash: {}",
+                            payment_hash
+                        );
+                        return Err(Error::WrongClnResponse.into());
+                    }
+                }
+            }
+            _ => {
+                tracing::warn!("CLN returned wrong response kind");
+                return Err(Error::Custom("CLN returned wrong response kind".to_string()).into());
+            }
+        };
+
+        Ok(status)
+    }
+}
+
+impl Cln {
+    async fn get_last_pay_index(&self) -> Result<Option<u64>, Error> {
+        let mut cln_client = self.cln_client.lock().await;
+        let cln_response = cln_client
+            .call(cln_rpc::Request::ListInvoices(ListinvoicesRequest {
+                index: None,
+                invstring: None,
+                label: None,
+                limit: None,
+                offer_id: None,
+                payment_hash: None,
+                start: None,
+            }))
+            .await
+            .map_err(Error::from)?;
+
+        match cln_response {
+            cln_rpc::Response::ListInvoices(invoice_res) => match invoice_res.invoices.last() {
+                Some(last_invoice) => Ok(last_invoice.pay_index),
+                None => Ok(None),
+            },
+            _ => {
+                tracing::warn!("CLN returned wrong response kind");
+                Err(Error::WrongClnResponse)
+            }
+        }
+    }
+}
+
+fn cln_invoice_status_to_mint_state(status: ListinvoicesInvoicesStatus) -> MintQuoteState {
+    match status {
+        ListinvoicesInvoicesStatus::UNPAID => MintQuoteState::Unpaid,
+        ListinvoicesInvoicesStatus::PAID => MintQuoteState::Paid,
+        ListinvoicesInvoicesStatus::EXPIRED => MintQuoteState::Unpaid,
+    }
+}

+ 4 - 2
crates/cdk/Cargo.toml

@@ -12,12 +12,13 @@ license.workspace = true
 
 [features]
 default = ["mint", "wallet"]
-mint = []
+mint = ["dep:futures"]
 wallet = ["dep:reqwest"]
 
 
 [dependencies]
 async-trait.workspace = true
+anyhow.workspace = true
 base64 = "0.22" # bitcoin uses v0.13 (optional dep)
 bitcoin = { workspace = true, features = [
     "serde",
@@ -38,8 +39,9 @@ serde_json.workspace = true
 serde_with = "3.4"
 tracing.workspace = true
 thiserror.workspace = true
+futures = { workspace = true, optional = true }
 url = "2.3"
-uuid = { version = "1", features = ["v4"] }
+uuid.workspace = true
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 tokio = { workspace = true, features = [

+ 14 - 14
crates/cdk/src/amount.rs

@@ -1,12 +1,12 @@
 //! CDK Amount
 //!
-//! Is any and will be treated as the unit of the wallet
+//! Is any unit and will be treated as the unit of the wallet
 
 use std::fmt;
 
 use serde::{Deserialize, Serialize};
 
-/// Number of satoshis
+/// Amount can be any unit
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 #[serde(transparent)]
 pub struct Amount(u64);
@@ -68,18 +68,6 @@ impl Amount {
     }
 }
 
-/// Kinds of targeting that are supported
-#[derive(
-    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
-)]
-pub enum SplitTarget {
-    /// Default target; least amount of proofs
-    #[default]
-    None,
-    /// Target amount for wallet to have most proofs that add up to value
-    Value(Amount),
-}
-
 impl Default for Amount {
     fn default() -> Self {
         Amount::ZERO
@@ -173,6 +161,18 @@ impl core::iter::Sum for Amount {
     }
 }
 
+/// Kinds of targeting that are supported
+#[derive(
+    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
+)]
+pub enum SplitTarget {
+    /// Default target; least amount of proofs
+    #[default]
+    None,
+    /// Target amount for wallet to have most proofs that add up to value
+    Value(Amount),
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

+ 72 - 0
crates/cdk/src/cdk_lightning/mod.rs

@@ -0,0 +1,72 @@
+//! CDK Mint Lightning
+
+use std::pin::Pin;
+
+use async_trait::async_trait;
+use futures::Stream;
+use lightning_invoice::{Bolt11Invoice, ParseOrSemanticError};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::nuts::{MeltQuoteState, MintQuoteState};
+
+/// CDK Lightning Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Lightning Error
+    #[error(transparent)]
+    Lightning(Box<dyn std::error::Error + Send + Sync>),
+    /// Serde Error
+    #[error(transparent)]
+    Serde(#[from] serde_json::Error),
+    /// AnyHow Error
+    #[error(transparent)]
+    Anyhow(#[from] anyhow::Error),
+    /// Parse Error
+    #[error(transparent)]
+    Parse(#[from] ParseOrSemanticError),
+}
+
+/// MintLighting Trait
+#[async_trait]
+pub trait MintLightning {
+    /// Mint Lightning Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Create a new invoice
+    async fn create_invoice(
+        &self,
+        msats: u64,
+        description: String,
+        unix_expiry: u64,
+    ) -> Result<Bolt11Invoice, Self::Err>;
+
+    /// Pay bolt11 invoice
+    async fn pay_invoice(
+        &self,
+        bolt11: Bolt11Invoice,
+        partial_msats: Option<u64>,
+        max_fee_msats: Option<u64>,
+    ) -> Result<PayInvoiceResponse, Self::Err>;
+
+    /// Listen for invoices to be paid to the mint
+    async fn wait_any_invoice(
+        &self,
+    ) -> Result<Pin<Box<dyn Stream<Item = Bolt11Invoice> + Send>>, Self::Err>;
+
+    /// Check the status of an incoming payment
+    async fn check_invoice_status(&self, payment_hash: &str) -> Result<MintQuoteState, Self::Err>;
+}
+
+/// Pay invoice response
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PayInvoiceResponse {
+    /// Payment hash
+    pub payment_hash: String,
+    /// Payment Preimage
+    pub payment_preimage: Option<String>,
+    /// Status
+    pub status: MeltQuoteState,
+    /// Totoal Amount Spent in msats
+    pub total_spent_msats: u64,
+}

+ 2 - 0
crates/cdk/src/lib.rs

@@ -5,6 +5,8 @@
 
 pub mod amount;
 pub mod cdk_database;
+#[cfg(feature = "mint")]
+pub mod cdk_lightning;
 pub mod dhke;
 pub mod error;
 #[cfg(feature = "mint")]

+ 1 - 0
misc/scripts/check-crates.sh

@@ -32,6 +32,7 @@ buildargs=(
     "-p cdk-redb --no-default-features --features mint"
     "-p cdk-sqlite --no-default-features --features mint"
     "-p cdk-sqlite --no-default-features --features wallet"
+    "-p cdk-cln"
     "--bin cdk-cli"
     "--examples"
 )