feat: cln lightning

This commit is contained in:
thesimplekid
2024-06-11 16:37:43 +01:00
parent bfe6ecb197
commit 16aeec92c7
9 changed files with 400 additions and 23 deletions

View File

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

20
crates/cdk-cln/Cargo.toml Normal file
View File

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

View File

@@ -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
crates/cdk-cln/src/lib.rs Normal file
View File

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

View File

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

View File

@@ -1,12 +1,12 @@
//! CDK Amount //! 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 std::fmt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Number of satoshis /// Amount can be any unit
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)] #[serde(transparent)]
pub struct Amount(u64); 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 { impl Default for Amount {
fn default() -> Self { fn default() -> Self {
Amount::ZERO 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

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

View File

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

View File

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