diff --git a/Cargo.toml b/Cargo.toml index 594694e3..4d66e91e 100644 --- a/Cargo.toml +++ b/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] diff --git a/crates/cdk-cln/Cargo.toml b/crates/cdk-cln/Cargo.toml new file mode 100644 index 00000000..222d3666 --- /dev/null +++ b/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 diff --git a/crates/cdk-cln/src/error.rs b/crates/cdk-cln/src/error.rs new file mode 100644 index 00000000..304dc9a0 --- /dev/null +++ b/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 for cdk::cdk_lightning::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-cln/src/lib.rs b/crates/cdk-cln/src/lib.rs new file mode 100644 index 00000000..104f282a --- /dev/null +++ b/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>, +} + +impl Cln { + pub async fn new(rpc_socket: PathBuf) -> Result { + 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 + 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, + max_fee_msats: Option, + ) -> Result { + 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 { + 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 { + 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, 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, + } +} diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index e4a9fc1b..fa8c2a14 100644 --- a/crates/cdk/Cargo.toml +++ b/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 = [ diff --git a/crates/cdk/src/amount.rs b/crates/cdk/src/amount.rs index 5877fc24..0ccdad97 100644 --- a/crates/cdk/src/amount.rs +++ b/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::*; diff --git a/crates/cdk/src/cdk_lightning/mod.rs b/crates/cdk/src/cdk_lightning/mod.rs new file mode 100644 index 00000000..b3032778 --- /dev/null +++ b/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), + /// 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 + From; + + /// Create a new invoice + async fn create_invoice( + &self, + msats: u64, + description: String, + unix_expiry: u64, + ) -> Result; + + /// Pay bolt11 invoice + async fn pay_invoice( + &self, + bolt11: Bolt11Invoice, + partial_msats: Option, + max_fee_msats: Option, + ) -> Result; + + /// Listen for invoices to be paid to the mint + async fn wait_any_invoice( + &self, + ) -> Result + Send>>, Self::Err>; + + /// Check the status of an incoming payment + async fn check_invoice_status(&self, payment_hash: &str) -> Result; +} + +/// Pay invoice response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PayInvoiceResponse { + /// Payment hash + pub payment_hash: String, + /// Payment Preimage + pub payment_preimage: Option, + /// Status + pub status: MeltQuoteState, + /// Totoal Amount Spent in msats + pub total_spent_msats: u64, +} diff --git a/crates/cdk/src/lib.rs b/crates/cdk/src/lib.rs index c7dc79c6..8dfcabe9 100644 --- a/crates/cdk/src/lib.rs +++ b/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")] diff --git a/misc/scripts/check-crates.sh b/misc/scripts/check-crates.sh index 5c38543b..c4705291 100755 --- a/misc/scripts/check-crates.sh +++ b/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" )