diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b56bd17833..05d2416ffe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,7 @@ jobs: -p cdk-cln, -p cdk-lnd, -p cdk-strike, + -p cdk-nwc, -p cdk-lnbits, -p cdk-fake-wallet, -p cdk-payment-processor, @@ -344,6 +345,7 @@ jobs: -p cdk-cln, -p cdk-lnd, -p cdk-strike, + -p cdk-nwc, -p cdk-mint-rpc, -p cdk-sqlite, -p cdk-mintd, diff --git a/Cargo.toml b/Cargo.toml index a2804597c4..ff390895af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ cdk-lnbits = { path = "./crates/cdk-lnbits", version = "=0.13.0" } cdk-lnd = { path = "./crates/cdk-lnd", version = "=0.13.0" } cdk-ldk-node = { path = "./crates/cdk-ldk-node", version = "=0.13.0" } cdk-strike = { path = "./crates/cdk-strike", version = "=0.13.0" } +cdk-nwc = { path = "./crates/cdk-nwc", version = "=0.13.0" } cdk-fake-wallet = { path = "./crates/cdk-fake-wallet", version = "=0.13.0" } cdk-ffi = { path = "./crates/cdk-ffi", version = "=0.13.0" } cdk-payment-processor = { path = "./crates/cdk-payment-processor", default-features = true, version = "=0.13.0" } diff --git a/README.md b/README.md index af57970f27..ec359179bf 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The project is split up into several crates in the `crates/` directory: * [**cdk-lnbits**](./crates/cdk-lnbits/): [LNbits](https://lnbits.com/) Lightning backend for mint. **Note: Only LNBits v1 API is supported.** * [**cdk-ldk-node**](./crates/cdk-ldk-node/): LDK Node Lightning backend for mint. * [**cdk-strike**](./crates/cdk-strike/): Strike Lightning backend for mint with BTC and USD support. + * [**cdk-strike**](./crates/cdk-nwc): Nostr Wallet Connect (NWC) Lightning backend for mint. * [**cdk-fake-wallet**](./crates/cdk-fake-wallet/): Fake Lightning backend for mint. To be used only for testing, quotes are automatically filled. * [**cdk-mint-rpc**](./crates/cdk-mint-rpc/): Mint management gRPC server and cli. * Binaries: diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index 8684a0c4ad..b128ab9bf0 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -28,8 +28,9 @@ cdk-axum = { workspace = true, features = ["auth"] } cdk-sqlite = { workspace = true } cdk-redb = { workspace = true } cdk-fake-wallet = { workspace = true } +cdk-nwc = { workspace = true } cdk-common = { workspace = true, features = ["mint", "wallet", "auth"] } -cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node", "prometheus", "strike"] } +cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node", "prometheus", "strike", "nwc"] } futures = { workspace = true, default-features = false, features = [ "executor", ] } diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index 9ef0416a5f..deda73dd41 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -294,6 +294,7 @@ fn create_ldk_settings( lnd: None, ldk_node: Some(ldk_config), fake_wallet: None, + nwc: None, grpc_processor: None, database: cdk_mintd::config::Database::default(), auth_database: None, diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs index 6d44a233f3..44355a58ba 100644 --- a/crates/cdk-integration-tests/src/shared.rs +++ b/crates/cdk-integration-tests/src/shared.rs @@ -215,6 +215,7 @@ pub fn create_fake_wallet_settings( lnd: None, ldk_node: None, fake_wallet: fake_wallet_config, + nwc: None, grpc_processor: None, database: Database { engine: DatabaseEngine::from_str(database).expect("valid database"), @@ -270,6 +271,7 @@ pub fn create_cln_settings( lnd: None, ldk_node: None, fake_wallet: None, + nwc: None, grpc_processor: None, database: cdk_mintd::config::Database::default(), auth_database: None, @@ -320,6 +322,7 @@ pub fn create_lnd_settings( ldk_node: None, lnd: Some(lnd_config), fake_wallet: None, + nwc: None, grpc_processor: None, database: cdk_mintd::config::Database::default(), auth_database: None, diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index ff61505ea2..c99a61a2b7 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -11,7 +11,7 @@ rust-version.workspace = true readme = "README.md" [features] -default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite", "strike"] +default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite", "strike", "nwc"] # Database features - at least one must be enabled sqlite = ["dep:cdk-sqlite"] postgres = ["dep:cdk-postgres"] @@ -24,6 +24,7 @@ fakewallet = ["dep:cdk-fake-wallet"] ldk-node = ["dep:cdk-ldk-node"] grpc-processor = ["dep:cdk-payment-processor", "cdk-signatory/grpc"] sqlcipher = ["sqlite", "cdk-sqlite/sqlcipher"] +nwc = ["dep:cdk-nwc"] # MSRV is not committed to with swagger enabled swagger = ["cdk-axum/swagger", "dep:utoipa", "dep:utoipa-swagger-ui"] auth = ["cdk/auth", "cdk-axum/auth", "cdk-sqlite?/auth", "cdk-postgres?/auth"] @@ -48,6 +49,7 @@ cdk-lnd = { workspace = true, optional = true } cdk-ldk-node = { workspace = true, optional = true } cdk-fake-wallet = { workspace = true, optional = true } cdk-strike = { workspace = true, optional = true } +cdk-nwc = { workspace = true, optional = true } cdk-axum.workspace = true cdk-signatory.workspace = true cdk-mint-rpc = { workspace = true, optional = true } diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 1d0add9290..fb81d66af6 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -85,7 +85,7 @@ max_connections = 20 connection_timeout_seconds = 10 [ln] -# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode', 'strike' +# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode', 'strike', 'nwc' ln_backend = "fakewallet" # min_mint=1 # max_mint=500000 @@ -159,6 +159,11 @@ reserve_fee_min = 1 min_delay_time = 1 max_delay_time = 3 +# [nwc] +# nwc_uri = "nostr+walletconnect://..." +# fee_percent = 0.02 +# reserve_fee_min = 1 + # [grpc_processor] # gRPC Payment Processor configuration # supported_units = ["sat"] diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index a51a45085a..23ca0b7219 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -140,6 +140,8 @@ pub enum LnBackend { GrpcProcessor, #[cfg(feature = "strike")] Strike, + #[cfg(feature = "nwc")] + Nwc, } impl std::str::FromStr for LnBackend { @@ -161,6 +163,8 @@ impl std::str::FromStr for LnBackend { "grpcprocessor" => Ok(LnBackend::GrpcProcessor), #[cfg(feature = "strike")] "strike" => Ok(LnBackend::Strike), + #[cfg(feature = "nwc")] + "nwc" => Ok(LnBackend::Nwc), _ => Err(format!("Unknown Lightning backend: {s}")), } } @@ -305,6 +309,14 @@ fn default_webserver_port() -> Option { Some(8091) } +#[cfg(feature = "nwc")] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Nwc { + pub nwc_uri: String, + pub fee_percent: f32, + pub reserve_fee_min: Amount, +} + #[cfg(feature = "fakewallet")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FakeWallet { @@ -492,6 +504,8 @@ pub struct Settings { pub lnd: Option, #[cfg(feature = "ldk-node")] pub ldk_node: Option, + #[cfg(feature = "nwc")] + pub nwc: Option, #[cfg(feature = "fakewallet")] pub fake_wallet: Option, pub grpc_processor: Option, @@ -638,6 +652,13 @@ impl Settings { "Strike backend requires a valid config." ) } + #[cfg(feature = "nwc")] + LnBackend::Nwc => { + assert!( + settings.nwc.is_some(), + "NWC backend requires a valid config." + ) + } } Ok(settings) diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index 476337da46..7e127a8d59 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -25,6 +25,8 @@ mod lnbits; mod lnd; #[cfg(feature = "management-rpc")] mod management_rpc; +#[cfg(feature = "nwc")] +mod nwc; #[cfg(feature = "prometheus")] mod prometheus; #[cfg(feature = "strike")] @@ -54,6 +56,8 @@ pub use lnd::*; #[cfg(feature = "management-rpc")] pub use management_rpc::*; pub use mint_info::*; +#[cfg(feature = "nwc")] +pub use nwc::*; #[cfg(feature = "prometheus")] pub use prometheus::*; #[cfg(feature = "strike")] @@ -156,6 +160,10 @@ impl Settings { LnBackend::Strike => { self.strike = Some(self.strike.clone().unwrap_or_default().from_env()); } + #[cfg(feature = "nwc")] + LnBackend::Nwc => { + self.nwc = Some(self.nwc.clone().unwrap_or_default().from_env()); + } LnBackend::None => bail!("Ln backend must be set"), #[allow(unreachable_patterns)] _ => bail!("Selected Ln backend is not enabled in this build"), diff --git a/crates/cdk-mintd/src/env_vars/nwc.rs b/crates/cdk-mintd/src/env_vars/nwc.rs new file mode 100644 index 0000000000..d516b26329 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/nwc.rs @@ -0,0 +1,32 @@ +//! NWC environment variables + +use std::env; + +use crate::config::Nwc; + +// NWC environment variables +pub const ENV_NWC_URI: &str = "CDK_MINTD_NWC_URI"; +pub const ENV_NWC_FEE_PERCENT: &str = "CDK_MINTD_NWC_FEE_PERCENT"; +pub const ENV_NWC_RESERVE_FEE_MIN: &str = "CDK_MINTD_NWC_RESERVE_FEE_MIN"; + +impl Nwc { + pub fn from_env(mut self) -> Self { + if let Ok(nwc_uri) = env::var(ENV_NWC_URI) { + self.nwc_uri = nwc_uri; + } + + if let Ok(fee_str) = env::var(ENV_NWC_FEE_PERCENT) { + if let Ok(fee) = fee_str.parse() { + self.fee_percent = fee; + } + } + + if let Ok(reserve_fee_str) = env::var(ENV_NWC_RESERVE_FEE_MIN) { + if let Ok(reserve_fee) = reserve_fee_str.parse::() { + self.reserve_fee_min = reserve_fee.into(); + } + } + + self + } +} diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index ca1361ac29..5da96d1664 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -21,7 +21,8 @@ use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; feature = "lnd", feature = "ldk-node", feature = "fakewallet", - feature = "grpc-processor" + feature = "grpc-processor", + feature = "nwc" ))] use cdk::nuts::nut17::SupportedMethods; use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path}; @@ -30,7 +31,8 @@ use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path} feature = "lnbits", feature = "lnd", feature = "ldk-node", - feature = "fakewallet" + feature = "fakewallet", + feature = "nwc" ))] use cdk::nuts::CurrencyUnit; #[cfg(feature = "auth")] @@ -581,6 +583,22 @@ async fn configure_lightning_backend( return Ok((mint_builder, webhook_routers)); } + #[cfg(feature = "nwc")] + LnBackend::Nwc => { + let nwc_settings = settings.clone().nwc.expect("NWC config defined"); + let nwc = nwc_settings + .setup(settings, CurrencyUnit::Sat, None, work_dir, None) + .await?; + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + CurrencyUnit::Sat, + mint_melt_limits, + Arc::new(nwc), + ) + .await?; + } LnBackend::None => { tracing::error!( "Payment backend was not set or feature disabled. {:?}", @@ -639,7 +657,8 @@ async fn configure_backend_for_unit( feature = "fakewallet", feature = "grpc-processor", feature = "ldk-node", - feature = "strike" + feature = "strike", + feature = "nwc" ))] { let nut17_supported = SupportedMethods::default_bolt11(unit); diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index 457b21cb40..46b54116a1 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -18,7 +18,8 @@ use cdk::nuts::CurrencyUnit; feature = "cln", feature = "lnd", feature = "ldk-node", - feature = "fakewallet" + feature = "fakewallet", + feature = "nwc" ))] use cdk::types::FeeReserve; @@ -369,3 +370,28 @@ impl config::Strike { Ok((strike, webhook_router)) } } + +#[cfg(feature = "nwc")] +#[async_trait] +impl LnBackendSetup for config::Nwc { + async fn setup( + &self, + settings: &Settings, + unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, + _kv_store: Option + Send + Sync>>, + ) -> anyhow::Result { + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let internal_settlement_only = settings.ln.internal_settlement_only; + + let nwc = + cdk_nwc::NWCWallet::new(&self.nwc_uri, fee_reserve, unit, internal_settlement_only) + .await?; + Ok(nwc) + } +} diff --git a/crates/cdk-nwc/Cargo.toml b/crates/cdk-nwc/Cargo.toml new file mode 100644 index 0000000000..91f408f0d2 --- /dev/null +++ b/crates/cdk-nwc/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cdk-nwc" +version.workspace = true +edition.workspace = true +authors = ["CDK Developers"] +license.workspace = true +homepage = "https://github.com/cashubtc/cdk" +repository = "https://github.com/cashubtc/cdk.git" +rust-version.workspace = true # MSRV +description = "CDK nwc backend" +readme = "README.md" + +[dependencies] +nwc = "0.43.0" +async-trait.workspace = true +bitcoin.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +futures.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +thiserror.workspace = true +serde_json.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } +rustls.workspace = true \ No newline at end of file diff --git a/crates/cdk-nwc/README.md b/crates/cdk-nwc/README.md new file mode 100644 index 0000000000..ee21e14b2b --- /dev/null +++ b/crates/cdk-nwc/README.md @@ -0,0 +1,74 @@ +# CDK NWC + +[![crates.io](https://img.shields.io/crates/v/cdk-nwc.svg)](https://crates.io/crates/cdk-nwc) +[![Documentation](https://docs.rs/cdk-nwc/badge.svg)](https://docs.rs/cdk-nwc) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE) + +Nostr Wallet Connect (NWC) backend implementation for the Cashu Development Kit (CDK). This backend implements the [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) specification for remote wallet control over Nostr. + +## Features + +- **Full Lightning Support**: Send and receive Lightning payments via NWC protocol +- **Real-time Notifications**: Uses NWC notifications to stream payment updates +- **BOLT11 Support**: Full support for standard Lightning invoices +- **Multi-Wallet Support**: Compatible with any NWC-enabled wallet that supports the required NIP-47 methods and notifications (ie. [Alby](https://getalby.com)) + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +cdk-nwc = "*" +``` + +## Quick Start + +```rust,no_run +use cdk_nwc::NWCWallet; +use cdk_common::common::FeeReserve; +use cdk_common::nuts::CurrencyUnit; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // NWC connection string from your wallet + let nwc_uri = "nostr+walletconnect://pubkey?relay=wss://relay.example.com&secret=..."; + + // Configure fee reserves + let fee_reserve = FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 0.01, + }; + + // Create the NWC wallet backend + let wallet = NWCWallet::new( + nwc_uri, + fee_reserve, + CurrencyUnit::Sat, + ).await?; + + Ok(()) +} +``` + +## Required NIP-47 Methods and Notifications + +For the NWC backend to work properly, the connected wallet must support the following NIP-47 methods: +- `pay_invoice` - Send Lightning payments +- `get_balance` - Query wallet balance +- `make_invoice` - Generate Lightning invoices +- `lookup_invoice` - Check payment status +- `list_transactions` - Transaction history +- `get_info` - Wallet capabilities + +And the following notification: +- `payment_received` - Real-time payment notifications + +## Supported Wallets + +Any wallet that supports the NWC protocol and the required methods/notifications can be used: +- [Alby](https://getalby.com) ✅ Full support + +## License + +This project is licensed under the [MIT License](../../LICENSE). diff --git a/crates/cdk-nwc/src/error.rs b/crates/cdk-nwc/src/error.rs new file mode 100644 index 0000000000..a138769086 --- /dev/null +++ b/crates/cdk-nwc/src/error.rs @@ -0,0 +1,63 @@ +//! CDK NWC Backend Error Types + +use thiserror::Error; + +/// NWC Error +#[derive(Debug, Error)] +pub enum Error { + /// NWC client error + #[error("NWC client error: {0}")] + Nwc(#[from] nwc::error::Error), + + /// Unknown invoice amount + #[error("Unknown invoice amount")] + UnknownInvoiceAmount, + + /// Invalid URI + #[error("Invalid NWC URI: {0}")] + InvalidUri(String), + + /// Unsupported methods + #[error("Wallet does not support required methods: {0}")] + UnsupportedMethods(String), + + /// Unsupported notifications + #[error("Wallet does not support required notifications: {0}")] + UnsupportedNotifications(String), + + /// Serde JSON error + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// Connection error + #[error("Connection error: {0}")] + Connection(String), +} + +impl From for cdk_common::payment::Error { + fn from(err: Error) -> Self { + match err { + Error::Nwc(e) => cdk_common::payment::Error::Lightning(Box::new(e)), + Error::UnknownInvoiceAmount => { + cdk_common::payment::Error::Custom("Unknown invoice amount".to_string()) + } + Error::InvalidUri(msg) => { + cdk_common::payment::Error::Custom(format!("Invalid NWC URI: {}", msg)) + } + Error::UnsupportedMethods(methods) => cdk_common::payment::Error::Custom(format!( + "Wallet does not support required methods: {}", + methods + )), + Error::UnsupportedNotifications(notifications) => { + cdk_common::payment::Error::Custom(format!( + "Wallet does not support required notifications: {}", + notifications + )) + } + Error::Json(e) => cdk_common::payment::Error::Serde(e), + Error::Connection(msg) => { + cdk_common::payment::Error::Custom(format!("Connection error: {}", msg)) + } + } + } +} diff --git a/crates/cdk-nwc/src/lib.rs b/crates/cdk-nwc/src/lib.rs new file mode 100644 index 0000000000..8074fce23d --- /dev/null +++ b/crates/cdk-nwc/src/lib.rs @@ -0,0 +1,634 @@ +//! CDK NWC LN Backend +//! +//! Used for connecting to a Nostr Wallet Connect (NWC) enabled wallet +//! to send and receive payments. +//! +//! The wallet uses NWC notifications to stream payment updates to the mint. + +#![doc = include_str!("../README.md")] +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +use std::cmp::max; +use std::pin::Pin; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use bitcoin::hashes::sha256::Hash; +use cdk_common::amount::{to_unit, Amount}; +use cdk_common::common::FeeReserve; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; +use cdk_common::payment::{ + self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, +}; +use cdk_common::util::hex; +use cdk_common::Bolt11Invoice; +use error::Error; +use futures::stream::StreamExt; +use futures::Stream; +use nwc::prelude::*; +use serde_json::Value; +use tokio::sync::Mutex; +use tokio_stream::wrappers::BroadcastStream; +use tokio_util::sync::CancellationToken; +use tracing::instrument; + +pub mod error; + +/// Default validation timeout in seconds +const VALIDATION_TIMEOUT_SECS: u64 = 15; + +/// NWC Wallet Backend +#[derive(Clone)] +pub struct NWCWallet { + /// NWC client + nwc_client: Arc, + /// Fee reserve configuration + fee_reserve: FeeReserve, + /// Channel sender for payment notifications + sender: tokio::sync::broadcast::Sender, + /// Channel receiver for payment notifications + receiver: Arc>, + /// Cancellation token for wait invoice + wait_invoice_cancel_token: CancellationToken, + /// Flag indicating if wait invoice is active + wait_invoice_is_active: Arc, + /// Currency unit + unit: CurrencyUnit, + /// Notification handler task handle + notification_handle: Arc>>>, +} + +impl NWCWallet { + /// Create new [`NWCWallet`] from NWC URI string + pub async fn new( + nwc_uri: &str, + fee_reserve: FeeReserve, + unit: CurrencyUnit, + internal_settlement_only: bool, + ) -> Result { + // NWC requires TLS for talking to the relay + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + let uri = NostrWalletConnectURI::from_str(nwc_uri) + .map_err(|e| Error::InvalidUri(e.to_string()))?; + + let nwc_client = Arc::new(NWC::new(uri)); + + let mut required_methods = vec![ + "make_invoice", + "lookup_invoice", + "list_transactions", + "get_info", + ]; + + if !internal_settlement_only { + required_methods.push("pay_invoice"); + } + + let required_notifications = &["payment_received"]; + + NWCWallet::validate_supported_methods_and_notifications( + &nwc_client, + VALIDATION_TIMEOUT_SECS, + &required_methods, + required_notifications, + ) + .await?; + + let (sender, receiver) = tokio::sync::broadcast::channel(100); + + let wallet = Self { + nwc_client, + fee_reserve, + sender, + receiver: Arc::new(receiver), + wait_invoice_cancel_token: CancellationToken::new(), + wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + unit, + notification_handle: Arc::new(Mutex::new(None)), + }; + + // Start notification handler + wallet.start_notification_handler().await?; + + Ok(wallet) + } + + /// Start the notification handler for payment updates + async fn start_notification_handler(&self) -> Result<(), Error> { + let nwc_client = self.nwc_client.clone(); + let sender = self.sender.clone(); + + let unit = self.unit.clone(); + let handle = tokio::spawn(async move { + tracing::info!("NWC: Starting notification handler"); + + if let Err(e) = nwc_client.subscribe_to_notifications().await { + tracing::error!("NWC: Failed to subscribe to notifications: {}", e); + return; + } + + let result = nwc_client + .handle_notifications(|notification| { + let sender = sender.clone(); + let unit = unit.clone(); + + async move { + match notification.notification_type { + NotificationType::PaymentReceived => { + if let Ok(payment) = notification.to_pay_notification() { + tracing::debug!( + "NWC: Payment received: {:?}", + payment.payment_hash + ); + + let payment_hash = match Hash::from_str(&payment.payment_hash) { + Ok(hash) => hash, + Err(e) => { + tracing::error!( + "NWC: Failed to parse payment hash: {}", + e + ); + return Ok(false); + } + }; + + let payment_id = + PaymentIdentifier::PaymentHash(*payment_hash.as_ref()); + + // nwc amounts are in msat, convert to target unit + let amount = + to_unit(payment.amount, &CurrencyUnit::Msat, &unit)?; + + let wait_payment_response = WaitPaymentResponse { + payment_identifier: payment_id, + payment_amount: amount, + unit: unit.clone(), + payment_id: payment.payment_hash, + }; + + if let Err(e) = sender.send(wait_payment_response) { + tracing::error!( + "NWC: Failed to send payment notification: {}", + e + ); + return Ok(true); // Exit the notification handler + } + } + } + NotificationType::PaymentSent => { + // We don't need to handle payment sent notifications + // Status can be checked via lookup_invoice when needed + } + } + Ok(false) // Continue processing + } + }) + .await; + + match result { + Ok(_) => { + tracing::info!("NWC: Notification handler completed normally"); + } + Err(e) => { + tracing::error!("NWC: Notification handler failed: {}", e); + } + } + }); + + let mut notification_handle = self.notification_handle.lock().await; + *notification_handle = Some(handle); + + Ok(()) + } + + /// Check if outgoing payment is already paid. + async fn check_outgoing_unpaid( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result<(), payment::Error> { + let pay_state = self.check_outgoing_payment(payment_identifier).await?; + + match pay_state.status { + MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => Ok(()), + MeltQuoteState::Paid => { + tracing::debug!("NWC: Melt attempted on invoice already paid"); + Err(payment::Error::InvoiceAlreadyPaid) + } + MeltQuoteState::Pending => { + tracing::debug!("NWC: Melt attempted on invoice already pending"); + Err(payment::Error::InvoicePaymentPending) + } + } + } +} + +#[async_trait] +impl MintPayment for NWCWallet { + type Err = payment::Error; + + #[instrument(skip_all)] + async fn get_settings(&self) -> Result { + Ok(serde_json::to_value(Bolt11Settings { + mpp: false, + unit: self.unit.clone(), + invoice_description: true, + amountless: true, + bolt12: false, + })?) + } + + #[instrument(skip_all)] + fn is_wait_invoice_active(&self) -> bool { + self.wait_invoice_is_active.load(Ordering::SeqCst) + } + + #[instrument(skip_all)] + fn cancel_wait_invoice(&self) { + self.wait_invoice_cancel_token.cancel() + } + + #[instrument(skip_all)] + async fn wait_payment_event( + &self, + ) -> Result + Send>>, Self::Err> { + tracing::info!("NWC: Starting stream for payment notifications"); + + self.wait_invoice_is_active.store(true, Ordering::SeqCst); + + let receiver = self.receiver.clone(); + let receiver_stream = BroadcastStream::new(receiver.resubscribe()); + + // Map the stream to handle BroadcastStreamRecvError and wrap in Event + let response_stream = receiver_stream.filter_map(|result| async move { + match result { + Ok(payment) => Some(Event::PaymentReceived(payment)), + Err(err) => { + tracing::warn!("Error in broadcast stream: {}", err); + None + } + } + }); + + Ok(Box::pin(response_stream)) + } + + #[instrument(skip_all)] + async fn get_payment_quote( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let (amount_msat, request_lookup_id) = match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let amount_msat: Amount = if let Some(melt_options) = bolt11_options.melt_options { + match melt_options { + MeltOptions::Amountless { amountless } => { + let amount_msat = amountless.amount_msat; + + if let Some(invoice_amount) = + bolt11_options.bolt11.amount_milli_satoshis() + { + if invoice_amount != u64::from(amount_msat) { + return Err(payment::Error::AmountMismatch); + } + } + amount_msat + } + MeltOptions::Mpp { mpp } => mpp.amount, + } + } else { + bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or_else(|| Error::UnknownInvoiceAmount)? + .into() + }; + + let payment_id = + PaymentIdentifier::PaymentHash(*bolt11_options.bolt11.payment_hash().as_ref()); + (amount_msat, Some(payment_id)) + } + OutgoingPaymentOptions::Bolt12(_) => { + return Err(payment::Error::UnsupportedUnit); + } + }; + + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + let fee = max(relative_fee_reserve, absolute_fee_reserve); + + Ok(PaymentQuoteResponse { + request_lookup_id, + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) + } + + #[instrument(skip_all)] + async fn make_payment( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; + let payment_identifier = + PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref()); + + self.check_outgoing_unpaid(&payment_identifier).await?; + + // Determine the amount to pay + let amount_msat: u64 = if let Some(melt_options) = bolt11_options.melt_options { + melt_options.amount_msat().into() + } else { + bolt11 + .amount_milli_satoshis() + .ok_or_else(|| Error::UnknownInvoiceAmount)? + }; + + // Create pay invoice request with amount for amountless invoices + let mut request = PayInvoiceRequest::new(bolt11.to_string()); + + // If the invoice is amountless, set the amount + if bolt11.amount_milli_satoshis().is_none() { + request.amount = Some(amount_msat); + } + + // Make payment through NWC + let response = self.nwc_client.pay_invoice(request).await.map_err(|e| { + tracing::error!("NWC payment failed: {}", e); + payment::Error::Lightning(Box::new(e)) + })?; + + let total_spent = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + let fee_paid = if let Some(fees) = response.fees_paid { + to_unit(fees, &CurrencyUnit::Msat, unit)? + } else { + Amount::ZERO + }; + + Ok(MakePaymentResponse { + payment_proof: Some(response.preimage), + payment_lookup_id: payment_identifier, + status: MeltQuoteState::Paid, + total_spent: total_spent + fee_paid, + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(_) => Err(payment::Error::UnsupportedUnit), + } + } + + #[instrument(skip_all)] + async fn create_incoming_payment_request( + &self, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + match options { + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let description = bolt11_options.description.unwrap_or_default(); + let amount = bolt11_options.amount; + let expiry = bolt11_options.unix_expiry; + + if amount == Amount::ZERO { + return Err(payment::Error::Custom( + "NWC requires invoice amount".to_string(), + )); + } + + // Convert amount to millisatoshis + let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?.into(); + + let request = MakeInvoiceRequest { + amount: amount_msat, + description: if description.is_empty() { + None + } else { + Some(description) + }, + description_hash: None, + expiry: None, // Expiry from bolt11_options is too long for NWC + }; + + let response = self.nwc_client.make_invoice(request).await.map_err(|e| { + tracing::error!("NWC create invoice failed: {}", e); + payment::Error::Lightning(Box::new(e)) + })?; + + let payment_hash = *Bolt11Invoice::from_str(&response.invoice)? + .payment_hash() + .as_ref(); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::PaymentHash(payment_hash), + request: response.invoice, + expiry, + }) + } + IncomingPaymentOptions::Bolt12(_) => Err(payment::Error::UnsupportedUnit), + } + } + + #[instrument(skip_all)] + async fn check_incoming_payment_status( + &self, + request_lookup_id: &PaymentIdentifier, + ) -> Result, Self::Err> { + // Handle only PaymentHash identifiers + let payment_hash = match request_lookup_id { + PaymentIdentifier::PaymentHash(hash) => hash, + _ => { + tracing::error!( + "NWC: Unsupported payment identifier type for check_incoming_payment_status" + ); + return Err(payment::Error::UnknownPaymentState); + } + }; + + let payment_hash_str = hex::encode(payment_hash); + let lookup_request = LookupInvoiceRequest { + payment_hash: Some(payment_hash_str), + invoice: None, + }; + + let lookup_response = match self.nwc_client.lookup_invoice(lookup_request).await { + Ok(invoice) => invoice, + Err(_) => return Ok(vec![]), // Invoice not found + }; + + if !matches!( + lookup_response.transaction_type, + Some(TransactionType::Incoming) + ) { + // Not an incoming payment + return Ok(vec![]); + } + + if lookup_response.settled_at.is_none() { + // Not settled + return Ok(vec![]); + } + + let response = WaitPaymentResponse { + payment_identifier: request_lookup_id.clone(), + payment_amount: to_unit(lookup_response.amount, &CurrencyUnit::Msat, &self.unit)?, + unit: self.unit.clone(), + payment_id: lookup_response.payment_hash, + }; + + Ok(vec![response]) + } + + #[instrument(skip_all)] + async fn check_outgoing_payment( + &self, + request_lookup_id: &PaymentIdentifier, + ) -> Result { + let payment_hash = match request_lookup_id { + PaymentIdentifier::PaymentHash(hash) => hash, + _ => { + tracing::error!( + "NWC: Unsupported payment identifier type for check_outgoing_payment" + ); + return Err(payment::Error::UnknownPaymentState); + } + }; + + let payment_hash_str = hex::encode(payment_hash); + let lookup_request = LookupInvoiceRequest { + payment_hash: Some(payment_hash_str), + invoice: None, + }; + + let lookup_response = match self.nwc_client.lookup_invoice(lookup_request).await { + Ok(invoice) => invoice, + Err(e) => { + tracing::warn!("NWC: Failed to lookup payment: {}", e); + return Ok(MakePaymentResponse { + payment_proof: None, + payment_lookup_id: request_lookup_id.clone(), + status: MeltQuoteState::Unknown, + total_spent: Amount::ZERO, + unit: self.unit.clone(), + }); + } + }; + + if !matches!( + lookup_response.transaction_type, + Some(TransactionType::Outgoing) + ) { + // Not an outgoing payment + return Err(payment::Error::UnknownPaymentState); + } + + let status = if lookup_response.settled_at.is_some() || lookup_response.preimage.is_some() { + MeltQuoteState::Paid + } else { + MeltQuoteState::Pending + }; + + let total_spent = if status == MeltQuoteState::Paid { + to_unit( + lookup_response.amount + lookup_response.fees_paid, + &CurrencyUnit::Msat, + &self.unit, + )? + } else { + Amount::ZERO + }; + + Ok(MakePaymentResponse { + payment_proof: lookup_response.preimage, + payment_lookup_id: request_lookup_id.clone(), + status, + total_spent, + unit: self.unit.clone(), + }) + } +} + +impl NWCWallet { + async fn validate_supported_methods_and_notifications( + client: &NWC, + timeout_secs: u64, + required_methods: &[&str], + required_notifications: &[&str], + ) -> Result<(), Error> { + let info = match tokio::time::timeout(Duration::from_secs(timeout_secs), client.get_info()) + .await + { + Ok(result) => result?, + Err(_) => return Err(Error::Connection("Timeout during validation".to_string())), + }; + + let missing_methods: Vec<&str> = required_methods + .iter() + .filter(|&method| !info.methods.contains(&method.to_string())) + .copied() + .collect(); + + if !missing_methods.is_empty() { + return Err(Error::UnsupportedMethods(missing_methods.join(", "))); + } + + let missing_notifications: Vec<&str> = required_notifications + .iter() + .filter(|¬ification| !info.notifications.contains(¬ification.to_string())) + .copied() + .collect(); + + if !missing_notifications.is_empty() { + return Err(Error::UnsupportedNotifications( + missing_notifications.join(", "), + )); + } + + Ok(()) + } +} + +impl Drop for NWCWallet { + fn drop(&mut self) { + tracing::info!("Drop called on NWCWallet"); + self.wait_invoice_cancel_token.cancel(); + + // Cancel notification handler task if it exists + // We need to use blocking approach since Drop is synchronous + if let Some(handle) = self + .notification_handle + .try_lock() + .ok() + .and_then(|mut guard| guard.take()) + { + handle.abort(); + } + + // Spawn background task to handle async unsubscription + let client = self.nwc_client.clone(); + tokio::spawn(async move { + if let Err(e) = client.unsubscribe_from_notifications().await { + tracing::warn!( + "Failed to unsubscribe from NWC notifications during cleanup: {}", + e + ); + } + }); + } +} diff --git a/crates/cdk-payment-processor/Cargo.toml b/crates/cdk-payment-processor/Cargo.toml index 4adddde4a8..76cb1e02f7 100644 --- a/crates/cdk-payment-processor/Cargo.toml +++ b/crates/cdk-payment-processor/Cargo.toml @@ -33,6 +33,7 @@ cdk-fake-wallet = { workspace = true, optional = true } cdk-sqlite = { workspace = true, optional = true } clap = { workspace = true, features = ["derive"] } cdk-strike = { workspace = true, optional = true } +cdk-nwc = { workspace = true, optional = true } serde.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/justfile b/justfile index 0e4282ec42..4b9eff1e7e 100644 --- a/justfile +++ b/justfile @@ -400,6 +400,7 @@ docs-strict: "-p cdk-cln" "-p cdk-lnd" "-p cdk-strike" + "-p cdk-nwc" "-p cdk-lnbits" "-p cdk-fake-wallet" "-p cdk-mint-rpc" diff --git a/misc/justfile.custom.just b/misc/justfile.custom.just index 629ca6bd80..cd9cc20486 100644 --- a/misc/justfile.custom.just +++ b/misc/justfile.custom.just @@ -51,6 +51,7 @@ clippy-each: "-p cdk-fake-wallet" "-p cdk-lnd" "-p cdk-strike" + "-p cdk-nwc" "-p cdk-mint-rpc" "--bin cdk-cli" "--bin cdk-mintd"