diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 36a435a..cfe9bdf 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -381,12 +381,6 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" -[[package]] -name = "bech32" -version = "0.10.0-beta" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" - [[package]] name = "bech32" version = "0.11.0" @@ -419,21 +413,6 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "bitcoin" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c85783c2fe40083ea54a33aa2f0ba58831d90fcd190f5bdc47e74e84d2a96ae" -dependencies = [ - "bech32 0.10.0-beta", - "bitcoin-internals 0.2.0", - "bitcoin_hashes 0.13.0", - "hex-conservative 0.1.2", - "hex_lit", - "secp256k1 0.28.2", - "serde", -] - [[package]] name = "bitcoin" version = "0.32.6" @@ -458,9 +437,6 @@ name = "bitcoin-internals" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" -dependencies = [ - "serde", -] [[package]] name = "bitcoin-internals" @@ -495,7 +471,6 @@ checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" dependencies = [ "bitcoin-internals 0.2.0", "hex-conservative 0.1.2", - "serde", ] [[package]] @@ -647,7 +622,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bf9751a6d33afd95a071d6f579638eb0b30f6364b0b944eafef3609baec5fac" dependencies = [ - "bitcoin 0.32.6", + "bitcoin", "cbor-diag", "ciborium", "instant", @@ -714,7 +689,7 @@ dependencies = [ "arc-swap", "async-trait", "bech32 0.9.1", - "bitcoin 0.32.6", + "bitcoin", "cbor-diag", "cdk-common", "cdk-signatory", @@ -746,7 +721,7 @@ checksum = "749a2e74031996f19b9dda606a6815566a5f5f7444f8827c8a523a557bc62ceb" dependencies = [ "anyhow", "async-trait", - "bitcoin 0.32.6", + "bitcoin", "cashu", "cbor-diag", "ciborium", @@ -771,7 +746,7 @@ dependencies = [ "anyhow", "async-trait", "bip39", - "bitcoin 0.32.6", + "bitcoin", "cdk-common", "clap", "getrandom 0.2.16", @@ -794,7 +769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbe958fb6d94d86492dba36692aecc11fa65d23937f66d41b780167b1c5301bf" dependencies = [ "async-trait", - "bitcoin 0.32.6", + "bitcoin", "cdk-common", "lightning-invoice", "rusqlite", @@ -2267,7 +2242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11209f386879b97198b2bfc9e9c1e5d42870825c6bd4376f17f95357244d6600" dependencies = [ "bech32 0.11.0", - "bitcoin 0.32.6", + "bitcoin", "lightning-types", "serde", ] @@ -2278,7 +2253,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2cd84d4e71472035903e43caded8ecc123066ce466329ccd5ae537a8d5488c7" dependencies = [ - "bitcoin 0.32.6", + "bitcoin", ] [[package]] @@ -2433,12 +2408,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "negentropy" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe" - [[package]] name = "negentropy" version = "0.5.0" @@ -2461,42 +2430,13 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nostr" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f08db214560a34bf7c4c1fea09a8461b9412bae58ba06e99ce3177d89fa1e0a6" -dependencies = [ - "aes", - "base64 0.21.7", - "bip39", - "bitcoin 0.31.2", - "cbc", - "chacha20", - "chacha20poly1305", - "getrandom 0.2.16", - "instant", - "js-sys", - "negentropy 0.3.1", - "once_cell", - "reqwest", - "scrypt", - "serde", - "serde_json", - "tracing", - "unicode-normalization", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "nostr" version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f30e6dcb36d88017587b0b5578d1ed3398afe8e4f45fdb910e48b8675aaf6f68" dependencies = [ + "aes", "base64 0.22.1", "bech32 0.11.0", "bip39", @@ -2521,7 +2461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1c75a8c2175d2785ba73cfddef21d1e30da5fbbdf158569b6808ba44973a15b" dependencies = [ "lru", - "nostr 0.43.0", + "nostr", "tokio", ] @@ -2535,8 +2475,8 @@ dependencies = [ "async-wsocket", "atomic-destructor", "lru", - "negentropy 0.5.0", - "nostr 0.43.0", + "negentropy", + "nostr", "nostr-database", "tokio", "tracing", @@ -2549,7 +2489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "599f8963d6a1522a13b1a2b0ea6e168acfc367706606f1d33fa595e91fa22db0" dependencies = [ "async-utility", - "nostr 0.43.0", + "nostr", "nostr-database", "nostr-relay-pool", "tokio", @@ -2648,6 +2588,17 @@ dependencies = [ "libc", ] +[[package]] +name = "nwc" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d5fc9d1a00916c5619b10c7eb028df2e10616bf3bccd0df17aa1f0c552f53a" +dependencies = [ + "nostr", + "nostr-relay-pool", + "tracing", +] + [[package]] name = "object" version = "0.36.7" @@ -2760,7 +2711,9 @@ dependencies = [ "ecash-402-wallet", "futures-util", "hex", - "nostr 0.33.0", + "nostr", + "nostr-sdk", + "nwc", "otrta-nostr", "rand 0.8.5", "regex", @@ -2786,7 +2739,7 @@ version = "1.0.0" dependencies = [ "anyhow", "chrono", - "nostr 0.43.0", + "nostr", "nostr-sdk", "serde", "serde_json", @@ -3894,18 +3847,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" -[[package]] -name = "secp256k1" -version = "0.28.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" -dependencies = [ - "bitcoin_hashes 0.13.0", - "rand 0.8.5", - "secp256k1-sys 0.9.2", - "serde", -] - [[package]] name = "secp256k1" version = "0.29.1" @@ -3929,15 +3870,6 @@ dependencies = [ "secp256k1-sys 0.11.0", ] -[[package]] -name = "secp256k1-sys" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" -dependencies = [ - "cc", -] - [[package]] name = "secp256k1-sys" version = "0.10.1" @@ -4075,7 +4007,6 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ - "indexmap 2.10.0", "itoa", "memchr", "ryu", diff --git a/crates/otrta-nostr/src/nip91_discovery.rs b/crates/otrta-nostr/src/nip91_discovery.rs index 87c7b2b..145de79 100644 --- a/crates/otrta-nostr/src/nip91_discovery.rs +++ b/crates/otrta-nostr/src/nip91_discovery.rs @@ -150,9 +150,6 @@ impl NostrProviderDiscovery { } fn parse_provider_from_event(&self, event: &Event) -> Result { - println!("Parsing event content: {}", event.content); - println!("Event tags: {:?}", event.tags); - let content: ProviderContent = match serde_json::from_str(&event.content) { Ok(content) => content, Err(e) => { @@ -166,7 +163,6 @@ impl NostrProviderDiscovery { let mut use_onion = content.use_onion.unwrap_or(false); let tag_map = parse_tags_to_map(&event.tags); - println!("Parsed tag map: {:?}", tag_map); if let Some(tag_urls) = tag_map.get("u") { for url in tag_urls { diff --git a/crates/otrta-ui/migrations/20251006000001_create_nwc_connections_table.down.sql b/crates/otrta-ui/migrations/20251006000001_create_nwc_connections_table.down.sql new file mode 100644 index 0000000..678fe64 --- /dev/null +++ b/crates/otrta-ui/migrations/20251006000001_create_nwc_connections_table.down.sql @@ -0,0 +1,4 @@ +-- Add down migration script here + +DROP TABLE IF EXISTS mint_auto_refill_settings; +DROP TABLE IF EXISTS nwc_connections; \ No newline at end of file diff --git a/crates/otrta-ui/migrations/20251006000001_create_nwc_connections_table.up.sql b/crates/otrta-ui/migrations/20251006000001_create_nwc_connections_table.up.sql new file mode 100644 index 0000000..ce9325a --- /dev/null +++ b/crates/otrta-ui/migrations/20251006000001_create_nwc_connections_table.up.sql @@ -0,0 +1,36 @@ +-- Add up migration script here + +CREATE TABLE nwc_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + connection_uri TEXT NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +); + +CREATE TABLE mint_auto_refill_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + mint_id INTEGER NOT NULL, + organization_id UUID NOT NULL, + nwc_connection_id UUID NOT NULL, + min_balance_threshold_msat BIGINT NOT NULL DEFAULT 1000000, + refill_amount_msat BIGINT NOT NULL DEFAULT 10000000, + is_enabled BOOLEAN DEFAULT TRUE, + last_refill_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + FOREIGN KEY (mint_id) REFERENCES mints(id) ON DELETE CASCADE, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (nwc_connection_id) REFERENCES nwc_connections(id) ON DELETE CASCADE, + UNIQUE(mint_id, organization_id) +); + +CREATE INDEX idx_nwc_connections_organization_id ON nwc_connections(organization_id); +CREATE INDEX idx_nwc_connections_is_active ON nwc_connections(is_active); +CREATE INDEX idx_mint_auto_refill_settings_organization_id ON mint_auto_refill_settings(organization_id); +CREATE INDEX idx_mint_auto_refill_settings_mint_id ON mint_auto_refill_settings(mint_id); +CREATE INDEX idx_mint_auto_refill_settings_is_enabled ON mint_auto_refill_settings(is_enabled); +CREATE INDEX idx_mint_auto_refill_settings_last_refill_at ON mint_auto_refill_settings(last_refill_at); \ No newline at end of file diff --git a/crates/otrta-ui/src/main.rs b/crates/otrta-ui/src/main.rs index d4d96c0..7649391 100644 --- a/crates/otrta-ui/src/main.rs +++ b/crates/otrta-ui/src/main.rs @@ -8,6 +8,7 @@ use background::BackgroundJobRunner; use connection::{DatabaseSettings, get_configuration}; use otrta::{ auth::{AuthConfig, AuthState, bearer_auth_middleware, nostr_auth_middleware_with_context}, + auto_refill_service::{AutoRefillConfig, start_auto_refill_service}, handlers, models::AppState, multimint_manager::MultimintManager, @@ -27,7 +28,7 @@ async fn main() { tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( std::env::var("RUST_LOG") - .unwrap_or_else(|_| "ecash-402-wallet=debug,tower_http=warning".into()), + .unwrap_or_else(|_| "ecash-402-wallet=debug,tower_http=warn".into()), )) .with(tracing_subscriber::fmt::layer()) .init(); @@ -56,6 +57,28 @@ async fn main() { let job_runner = BackgroundJobRunner::new(Arc::clone(&app_state)); job_runner.start_all_jobs().await; + let auto_refill_config = AutoRefillConfig { + enabled: std::env::var("AUTO_REFILL_ENABLED") + .unwrap_or_else(|_| "true".to_string()) + .parse() + .unwrap_or(true), + check_interval_seconds: std::env::var("AUTO_REFILL_CHECK_INTERVAL_SECONDS") + .unwrap_or_else(|_| "300".to_string()) + .parse() + .unwrap_or(300), + min_refill_interval_minutes: std::env::var("AUTO_REFILL_MIN_INTERVAL_MINUTES") + .unwrap_or_else(|_| "60".to_string()) + .parse() + .unwrap_or(60), + }; + + let _auto_refill_handle = start_auto_refill_service( + auto_refill_config, + connection_pool.clone(), + app_state.multimint_manager.clone(), + ) + .await; + let auth_config = AuthConfig { enabled: configuration.application.enable_authentication, max_age_seconds: 300, @@ -217,6 +240,51 @@ async fn main() { "/api/models/pricing-comparison", get(handlers::get_model_pricing_comparison), ) + .route( + "/api/nwc/connections", + get(handlers::get_nwc_connections_handler), + ) + .route( + "/api/nwc/connections", + post(handlers::create_nwc_connection_handler), + ) + .route( + "/api/nwc/connections/{connection_id}", + get(handlers::get_nwc_connection_handler), + ) + .route( + "/api/nwc/connections/{connection_id}", + put(handlers::update_nwc_connection_handler), + ) + .route( + "/api/nwc/connections/{connection_id}", + delete(handlers::delete_nwc_connection_handler), + ) + .route("/api/nwc/test", post(handlers::test_nwc_connection_handler)) + .route( + "/api/nwc/connections/pay", + post(handlers::pay_invoice_with_nwc_handler), + ) + .route( + "/api/nwc/auto-refill", + get(handlers::get_mint_auto_refill_settings_handler), + ) + .route( + "/api/nwc/auto-refill", + post(handlers::create_mint_auto_refill_handler), + ) + .route( + "/api/nwc/auto-refill/mint/{mint_id}", + get(handlers::get_mint_auto_refill_by_mint_handler), + ) + .route( + "/api/nwc/auto-refill/{settings_id}", + put(handlers::update_mint_auto_refill_handler), + ) + .route( + "/api/nwc/auto-refill/{settings_id}", + delete(handlers::delete_mint_auto_refill_handler), + ) .with_state(app_state.clone()); let mut unprotected_routes = Router::new() diff --git a/crates/otrta/Cargo.toml b/crates/otrta/Cargo.toml index 7a99090..ecb4d32 100644 --- a/crates/otrta/Cargo.toml +++ b/crates/otrta/Cargo.toml @@ -14,7 +14,9 @@ chrono = { version = "0.4", features = ["serde"] } dotenv = "0.15" futures-util = "0.3" hex = "0.4" -nostr = "0.33" +nostr = "0.43" +nostr-sdk = { version = "0.43", default-features = false, features = ["nip04", "nip47"] } +nwc = "0.43" rand = "0.8" regex = "1.10" reqwest = { version = "0.12", features = ["json", "stream", "socks"] } diff --git a/crates/otrta/src/auth.rs b/crates/otrta/src/auth.rs index 088e308..d847c2c 100644 --- a/crates/otrta/src/auth.rs +++ b/crates/otrta/src/auth.rs @@ -283,8 +283,8 @@ fn validate_auth_event( let mut url_found = false; let mut method_found = false; - for tag in &event.tags { - let values = tag.as_vec(); + for tag in event.tags.iter() { + let values = tag.clone().to_vec(); if values.len() >= 2 { match values[0].as_str() { "u" => { diff --git a/crates/otrta/src/auto_refill_service.rs b/crates/otrta/src/auto_refill_service.rs new file mode 100644 index 0000000..03c5b39 --- /dev/null +++ b/crates/otrta/src/auto_refill_service.rs @@ -0,0 +1,219 @@ +use std::time::Duration; +use tokio::time::{interval, sleep}; +use tracing::{debug, error, info, warn}; + +use crate::{ + db::{ + mint::get_mint_by_id, + nwc::{get_enabled_mint_auto_refill_settings, update_last_refill_time}, + }, + error::AppError, + multimint_manager::MultimintManager, + nwc_client::NwcManager, +}; + +pub struct AutoRefillService { + db_pool: sqlx::PgPool, + multimint_manager: std::sync::Arc, + nwc_manager: NwcManager, + check_interval: Duration, + min_refill_interval: Duration, +} + +impl AutoRefillService { + pub fn new( + db_pool: sqlx::PgPool, + multimint_manager: std::sync::Arc, + check_interval_seconds: u64, + min_refill_interval_minutes: u64, + ) -> Self { + let nwc_manager = NwcManager::new(db_pool.clone()); + + Self { + db_pool, + multimint_manager, + nwc_manager, + check_interval: Duration::from_secs(check_interval_seconds), + min_refill_interval: Duration::from_secs(min_refill_interval_minutes * 60), + } + } + + pub async fn start(&self) { + info!( + "Starting auto-refill service with check interval: {:?}", + self.check_interval + ); + + let mut interval_timer = interval(self.check_interval); + + loop { + interval_timer.tick().await; + + if let Err(e) = self.check_and_refill_balances().await { + error!("Error in auto-refill service: {}", e); + } + } + } + + async fn check_and_refill_balances(&self) -> Result<(), AppError> { + debug!("Checking balances for auto-refill"); + + let settings = get_enabled_mint_auto_refill_settings(&self.db_pool).await?; + + if settings.is_empty() { + debug!("No enabled auto-refill settings found"); + return Ok(()); + } + + info!("Found {} enabled auto-refill settings", settings.len()); + + for setting in settings { + if let Err(e) = self.process_mint_refill(&setting).await { + error!( + "Failed to process refill for mint {} in organization {}: {}", + setting.mint_id, setting.organization_id, e + ); + } + + sleep(Duration::from_millis(500)).await; + } + + Ok(()) + } + + async fn process_mint_refill( + &self, + setting: &crate::db::nwc::MintAutoRefillSettings, + ) -> Result<(), AppError> { + if let Some(last_refill) = setting.last_refill_at { + let time_since_last_refill = chrono::Utc::now().signed_duration_since(last_refill); + if time_since_last_refill.to_std().unwrap_or(Duration::ZERO) < self.min_refill_interval + { + debug!( + "Skipping refill for mint {} - too soon since last refill", + setting.mint_id + ); + return Ok(()); + } + } + + let mint = get_mint_by_id(&self.db_pool, setting.mint_id) + .await? + .ok_or_else(|| { + warn!("Mint not found: {}", setting.mint_id); + AppError::NotFound + })?; + + if !mint.is_active { + debug!("Skipping inactive mint: {}", setting.mint_id); + return Ok(()); + } + + let wallet = self + .multimint_manager + .get_or_create_multimint(&setting.organization_id) + .await?; + + let balance = match wallet.get_balance_for_mint(&mint.mint_url).await { + Ok(balance) => balance, + Err(e) => { + warn!("Failed to get balance for mint {}: {}", mint.mint_url, e); + return Ok(()); + } + }; + + debug!( + "Mint {} balance: {} msat, threshold: {} msat", + mint.mint_url, balance, setting.min_balance_threshold_msat + ); + + if balance >= setting.min_balance_threshold_msat as u64 { + debug!( + "Mint {} balance above threshold, no refill needed", + mint.mint_url + ); + return Ok(()); + } + + info!( + "Mint {} balance ({} msat) below threshold ({} msat), initiating refill of {} msat", + mint.mint_url, balance, setting.min_balance_threshold_msat, setting.refill_amount_msat + ); + + match self.execute_refill(setting, &mint.mint_url).await { + Ok(invoice) => { + info!( + "Successfully initiated refill for mint {}: {}", + mint.mint_url, invoice + ); + + if let Err(e) = update_last_refill_time(&self.db_pool, &setting.id).await { + error!("Failed to update last refill time: {}", e); + } + } + Err(e) => { + error!("Failed to execute refill for mint {}: {}", mint.mint_url, e); + return Err(e); + } + } + + Ok(()) + } + + async fn execute_refill( + &self, + setting: &crate::db::nwc::MintAutoRefillSettings, + mint_url: &str, + ) -> Result { + let invoice = self + .nwc_manager + .request_mint_refill( + &setting.nwc_connection_id, + &setting.organization_id, + setting.refill_amount_msat as u64, + mint_url, + ) + .await?; + + Ok(invoice) + } +} + +#[derive(Clone)] +pub struct AutoRefillConfig { + pub enabled: bool, + pub check_interval_seconds: u64, + pub min_refill_interval_minutes: u64, +} + +impl Default for AutoRefillConfig { + fn default() -> Self { + Self { + enabled: true, + check_interval_seconds: 300, + min_refill_interval_minutes: 60, + } + } +} + +pub async fn start_auto_refill_service( + config: AutoRefillConfig, + db_pool: sqlx::PgPool, + multimint_manager: std::sync::Arc, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + if !config.enabled { + info!("Auto-refill service is disabled"); + return; + } + + let service = AutoRefillService::new( + db_pool, + multimint_manager, + config.check_interval_seconds, + config.min_refill_interval_minutes, + ); + + service.start().await; + }) +} diff --git a/crates/otrta/src/db/mod.rs b/crates/otrta/src/db/mod.rs index c29e7ff..f2ef746 100644 --- a/crates/otrta/src/db/mod.rs +++ b/crates/otrta/src/db/mod.rs @@ -4,6 +4,7 @@ pub mod helpers; pub mod mint; pub mod model_pricing; pub mod models; +pub mod nwc; pub mod organizations; pub mod provider; pub mod server_config; diff --git a/crates/otrta/src/db/nwc.rs b/crates/otrta/src/db/nwc.rs new file mode 100644 index 0000000..9a778a6 --- /dev/null +++ b/crates/otrta/src/db/nwc.rs @@ -0,0 +1,375 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct NwcConnection { + pub id: Uuid, + pub organization_id: Uuid, + pub name: String, + pub connection_uri: String, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct MintAutoRefillSettings { + pub id: Uuid, + pub mint_id: i32, + pub organization_id: Uuid, + pub nwc_connection_id: Uuid, + pub min_balance_threshold_msat: i64, + pub refill_amount_msat: i64, + pub is_enabled: bool, + pub last_refill_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateNwcConnectionRequest { + pub name: String, + pub connection_uri: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateNwcConnectionRequest { + pub name: Option, + pub connection_uri: Option, + pub is_active: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateMintAutoRefillRequest { + pub mint_id: i32, + pub nwc_connection_id: Uuid, + pub min_balance_threshold_msat: i64, + pub refill_amount_msat: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateMintAutoRefillRequest { + pub nwc_connection_id: Option, + pub min_balance_threshold_msat: Option, + pub refill_amount_msat: Option, + pub is_enabled: Option, +} + +pub async fn create_nwc_connection( + pool: &PgPool, + organization_id: &Uuid, + request: CreateNwcConnectionRequest, +) -> Result { + let connection = sqlx::query_as::<_, NwcConnection>( + "INSERT INTO nwc_connections (organization_id, name, connection_uri, updated_at) + VALUES ($1, $2, $3, NOW()) + RETURNING id, organization_id, name, connection_uri, is_active, created_at, updated_at", + ) + .bind(organization_id) + .bind(&request.name) + .bind(&request.connection_uri) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to create NWC connection: {}", e); + AppError::InternalServerError + })?; + + Ok(connection) +} + +pub async fn get_nwc_connections_for_organization( + pool: &PgPool, + organization_id: &Uuid, +) -> Result, AppError> { + let connections = sqlx::query_as::<_, NwcConnection>( + "SELECT id, organization_id, name, connection_uri, is_active, created_at, updated_at + FROM nwc_connections + WHERE organization_id = $1 + ORDER BY created_at DESC", + ) + .bind(organization_id) + .fetch_all(pool) + .await + .map_err(|e| { + tracing::error!("Failed to get NWC connections: {}", e); + AppError::InternalServerError + })?; + + Ok(connections) +} + +pub async fn get_active_nwc_connection_for_organization( + pool: &PgPool, + organization_id: &Uuid, +) -> Result { + let connections = sqlx::query_as::<_, NwcConnection>( + "SELECT id, organization_id, name, connection_uri, is_active, created_at, updated_at + FROM nwc_connections + WHERE organization_id = $1 AND is_active = TRUE + ORDER BY created_at DESC", + ) + .bind(organization_id) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to get active NWC connections: {}", e); + AppError::InternalServerError + })?; + + Ok(connections) +} + +pub async fn get_active_nwc_connections_for_organization( + pool: &PgPool, + organization_id: &Uuid, +) -> Result, AppError> { + let connections = sqlx::query_as::<_, NwcConnection>( + "SELECT id, organization_id, name, connection_uri, is_active, created_at, updated_at + FROM nwc_connections + WHERE organization_id = $1 AND is_active = TRUE + ORDER BY created_at DESC", + ) + .bind(organization_id) + .fetch_all(pool) + .await + .map_err(|e| { + tracing::error!("Failed to get active NWC connections: {}", e); + AppError::InternalServerError + })?; + + Ok(connections) +} + +pub async fn get_nwc_connection_by_id( + pool: &PgPool, + id: &Uuid, + organization_id: &Uuid, +) -> Result, AppError> { + let connection = sqlx::query_as::<_, NwcConnection>( + "SELECT id, organization_id, name, connection_uri, is_active, created_at, updated_at + FROM nwc_connections + WHERE id = $1 AND organization_id = $2", + ) + .bind(id) + .bind(organization_id) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to get NWC connection by ID: {}", e); + AppError::InternalServerError + })?; + + Ok(connection) +} + +pub async fn update_nwc_connection( + pool: &PgPool, + id: &Uuid, + organization_id: &Uuid, + request: UpdateNwcConnectionRequest, +) -> Result, AppError> { + let connection = sqlx::query_as::<_, NwcConnection>( + "UPDATE nwc_connections + SET name = COALESCE($3, name), + connection_uri = COALESCE($4, connection_uri), + is_active = COALESCE($5, is_active), + updated_at = NOW() + WHERE id = $1 AND organization_id = $2 + RETURNING id, organization_id, name, connection_uri, is_active, created_at, updated_at", + ) + .bind(id) + .bind(organization_id) + .bind(request.name) + .bind(request.connection_uri) + .bind(request.is_active) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to update NWC connection: {}", e); + AppError::InternalServerError + })?; + + Ok(connection) +} + +pub async fn delete_nwc_connection( + pool: &PgPool, + id: &Uuid, + organization_id: &Uuid, +) -> Result { + let result = sqlx::query("DELETE FROM nwc_connections WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(organization_id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to delete NWC connection: {}", e); + AppError::InternalServerError + })?; + + Ok(result.rows_affected() > 0) +} + +pub async fn create_mint_auto_refill_settings( + pool: &PgPool, + organization_id: &Uuid, + request: CreateMintAutoRefillRequest, +) -> Result { + let settings = sqlx::query_as::<_, MintAutoRefillSettings>( + "INSERT INTO mint_auto_refill_settings (mint_id, organization_id, nwc_connection_id, min_balance_threshold_msat, refill_amount_msat, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + RETURNING id, mint_id, organization_id, nwc_connection_id, min_balance_threshold_msat, refill_amount_msat, is_enabled, last_refill_at, created_at, updated_at", + ) + .bind(request.mint_id) + .bind(organization_id) + .bind(request.nwc_connection_id) + .bind(request.min_balance_threshold_msat) + .bind(request.refill_amount_msat) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to create mint auto-refill settings: {}", e); + AppError::InternalServerError + })?; + + Ok(settings) +} + +pub async fn get_mint_auto_refill_settings_for_organization( + pool: &PgPool, + organization_id: &Uuid, +) -> Result, AppError> { + let settings = sqlx::query_as::<_, MintAutoRefillSettings>( + "SELECT id, mint_id, organization_id, nwc_connection_id, min_balance_threshold_msat, refill_amount_msat, is_enabled, last_refill_at, created_at, updated_at + FROM mint_auto_refill_settings + WHERE organization_id = $1 + ORDER BY created_at DESC", + ) + .bind(organization_id) + .fetch_all(pool) + .await + .map_err(|e| { + tracing::error!("Failed to get mint auto-refill settings: {}", e); + AppError::InternalServerError + })?; + + Ok(settings) +} + +pub async fn get_enabled_mint_auto_refill_settings( + pool: &PgPool, +) -> Result, AppError> { + let settings = sqlx::query_as::<_, MintAutoRefillSettings>( + "SELECT mars.id, mars.mint_id, mars.organization_id, mars.nwc_connection_id, + mars.min_balance_threshold_msat, mars.refill_amount_msat, mars.is_enabled, + mars.last_refill_at, mars.created_at, mars.updated_at + FROM mint_auto_refill_settings mars + JOIN nwc_connections nwc ON mars.nwc_connection_id = nwc.id + JOIN mints m ON mars.mint_id = m.id + WHERE mars.is_enabled = TRUE AND nwc.is_active = TRUE AND m.is_active = TRUE + ORDER BY mars.last_refill_at ASC NULLS FIRST", + ) + .fetch_all(pool) + .await + .map_err(|e| { + tracing::error!("Failed to get enabled mint auto-refill settings: {}", e); + AppError::InternalServerError + })?; + + Ok(settings) +} + +pub async fn get_mint_auto_refill_settings_by_mint( + pool: &PgPool, + mint_id: i32, + organization_id: &Uuid, +) -> Result, AppError> { + let settings = sqlx::query_as::<_, MintAutoRefillSettings>( + "SELECT id, mint_id, organization_id, nwc_connection_id, min_balance_threshold_msat, refill_amount_msat, is_enabled, last_refill_at, created_at, updated_at + FROM mint_auto_refill_settings + WHERE mint_id = $1 AND organization_id = $2", + ) + .bind(mint_id) + .bind(organization_id) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to get mint auto-refill settings by mint: {}", e); + AppError::InternalServerError + })?; + + Ok(settings) +} + +pub async fn update_mint_auto_refill_settings( + pool: &PgPool, + id: &Uuid, + organization_id: &Uuid, + request: UpdateMintAutoRefillRequest, +) -> Result, AppError> { + let settings = sqlx::query_as::<_, MintAutoRefillSettings>( + "UPDATE mint_auto_refill_settings + SET nwc_connection_id = COALESCE($3, nwc_connection_id), + min_balance_threshold_msat = COALESCE($4, min_balance_threshold_msat), + refill_amount_msat = COALESCE($5, refill_amount_msat), + is_enabled = COALESCE($6, is_enabled), + updated_at = NOW() + WHERE id = $1 AND organization_id = $2 + RETURNING id, mint_id, organization_id, nwc_connection_id, min_balance_threshold_msat, refill_amount_msat, is_enabled, last_refill_at, created_at, updated_at", + ) + .bind(id) + .bind(organization_id) + .bind(request.nwc_connection_id) + .bind(request.min_balance_threshold_msat) + .bind(request.refill_amount_msat) + .bind(request.is_enabled) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to update mint auto-refill settings: {}", e); + AppError::InternalServerError + })?; + + Ok(settings) +} + +pub async fn update_last_refill_time(pool: &PgPool, id: &Uuid) -> Result { + let result = sqlx::query( + "UPDATE mint_auto_refill_settings + SET last_refill_at = NOW(), updated_at = NOW() + WHERE id = $1", + ) + .bind(id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to update last refill time: {}", e); + AppError::InternalServerError + })?; + + Ok(result.rows_affected() > 0) +} + +pub async fn delete_mint_auto_refill_settings( + pool: &PgPool, + id: &Uuid, + organization_id: &Uuid, +) -> Result { + let result = + sqlx::query("DELETE FROM mint_auto_refill_settings WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(organization_id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to delete mint auto-refill settings: {}", e); + AppError::InternalServerError + })?; + + Ok(result.rows_affected() > 0) +} diff --git a/crates/otrta/src/error.rs b/crates/otrta/src/error.rs index f6e6f59..cf1b424 100644 --- a/crates/otrta/src/error.rs +++ b/crates/otrta/src/error.rs @@ -13,6 +13,7 @@ pub enum AppError { InternalServerError, ValidationError(String), DatabaseError(String), + BadRequest(String), } impl fmt::Display for AppError { @@ -23,6 +24,7 @@ impl fmt::Display for AppError { AppError::InternalServerError => write!(f, "Internal server error"), AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg), AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg), + AppError::BadRequest(msg) => write!(f, "Bad request: {}", msg), } } } @@ -37,6 +39,7 @@ impl IntoResponse for AppError { } AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.as_str()), AppError::DatabaseError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.as_str()), }; let body = Json(json!({ @@ -48,3 +51,9 @@ impl IntoResponse for AppError { } impl std::error::Error for AppError {} + +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + AppError::DatabaseError(err.to_string()) + } +} diff --git a/crates/otrta/src/handlers/lightning.rs b/crates/otrta/src/handlers/lightning.rs index 59dae6c..7db0d1f 100644 --- a/crates/otrta/src/handlers/lightning.rs +++ b/crates/otrta/src/handlers/lightning.rs @@ -7,6 +7,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use serde_json::{self, json}; use std::sync::Arc; +use uuid::Uuid; #[derive(Deserialize)] pub struct CreateLightningPaymentRequest { @@ -526,3 +527,53 @@ pub async fn create_lightning_invoice_handler( mint_url: payload.mint_url.clone().unwrap_or_default(), // Include the mint_url used for the invoice })) } + +pub async fn check_lightning_payment_nwc( + state: &Arc, + organization_id: &Uuid, + quote_id: &str, + mint_url: &str, +) -> Result, (StatusCode, Json)> { + let org_wallet = match state + .multimint_manager + .get_or_create_multimint(organization_id) + .await + { + Ok(wallet) => wallet, + Err(_) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + json!({"error": {"message": "Failed to get organization wallet", "type": "wallet_error"}}), + ), + )) + } + }; + + let wallet = match org_wallet.get_wallet_for_mint(mint_url).await { + Some(wallet) => { + eprintln!("DEBUG: Found wallet for specified mint"); + wallet + } + None => { + eprintln!("DEBUG: No wallet found for specified mint: {}", mint_url); + return Err(( + StatusCode::NOT_FOUND, + Json(json!({ + "error": { + "message": format!("No wallet found for mint: {}. Make sure this mint is added to your wallet.", mint_url), + "type": "wallet_not_found" + } + })), + )); + } + }; + + let status = wallet.check_mint_quote("e_id).await.unwrap(); + + Ok(Json(PaymentStatusResponse { + quote_id: quote_id.to_string(), + state: status.to_string(), + amount: 0, + })) +} diff --git a/crates/otrta/src/handlers/mod.rs b/crates/otrta/src/handlers/mod.rs index b5bdaf5..978b19b 100644 --- a/crates/otrta/src/handlers/mod.rs +++ b/crates/otrta/src/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod lightning; pub mod mints; pub mod models; pub mod multimint; +pub mod nwc; pub mod providers; pub mod users; pub mod wallet; @@ -18,6 +19,7 @@ pub use lightning::*; pub use mints::*; pub use models::*; pub use multimint::*; +pub use nwc::*; pub use providers::*; pub use users::*; pub use wallet::*; diff --git a/crates/otrta/src/handlers/nwc.rs b/crates/otrta/src/handlers/nwc.rs new file mode 100644 index 0000000..9bf18a2 --- /dev/null +++ b/crates/otrta/src/handlers/nwc.rs @@ -0,0 +1,254 @@ +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use std::sync::Arc; + +use crate::{ + db::nwc::{ + create_mint_auto_refill_settings, create_nwc_connection, delete_mint_auto_refill_settings, + delete_nwc_connection, get_active_nwc_connection_for_organization, + get_mint_auto_refill_settings_by_mint, get_mint_auto_refill_settings_for_organization, + get_nwc_connection_by_id, get_nwc_connections_for_organization, + update_mint_auto_refill_settings, update_nwc_connection, CreateMintAutoRefillRequest, + CreateNwcConnectionRequest, UpdateMintAutoRefillRequest, UpdateNwcConnectionRequest, + }, + error::AppError, + models::{AppState, UserContext}, + nwc_client::NwcManager, +}; + +#[derive(Debug, Serialize)] +pub struct NwcConnectionsResponse { + pub connections: Vec, +} + +#[derive(Debug, Serialize)] +pub struct MintAutoRefillSettingsResponse { + pub settings: Vec, +} + +#[derive(Debug, Serialize)] +pub struct NwcTestResponse { + pub success: bool, + pub methods: Vec, + pub alias: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TestNwcConnectionRequest { + pub connection_uri: String, +} + +#[derive(Debug, Deserialize)] +pub struct PayInvoiceRequest { + pub invoice: String, + pub quote_id: String, + pub mint_url: String, +} + +#[derive(Debug, Serialize)] +pub struct PayInvoiceResponse { + pub success: bool, + pub preimage: Option, + pub error: Option, +} + +pub async fn create_nwc_connection_handler( + State(state): State>, + Extension(user_context): Extension, + Json(request): Json, +) -> Result, AppError> { + let nwc_manager = NwcManager::new(state.db.clone()); + + nwc_manager.test_connection(&request.connection_uri).await?; + + let connection = + create_nwc_connection(&state.db, &user_context.organization_id, request).await?; + + Ok(Json(connection)) +} + +pub async fn get_nwc_connections_handler( + State(state): State>, + Extension(user_context): Extension, +) -> Result, AppError> { + let connections = + get_nwc_connections_for_organization(&state.db, &user_context.organization_id).await?; + + Ok(Json(NwcConnectionsResponse { connections })) +} + +pub async fn get_nwc_connection_handler( + State(state): State>, + Extension(user_context): Extension, + Path(connection_id): Path, +) -> Result, AppError> { + let connection = + get_nwc_connection_by_id(&state.db, &connection_id, &user_context.organization_id) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(connection)) +} + +pub async fn update_nwc_connection_handler( + State(state): State>, + Extension(user_context): Extension, + Path(connection_id): Path, + Json(request): Json, +) -> Result, AppError> { + if let Some(ref connection_uri) = request.connection_uri { + let nwc_manager = NwcManager::new(state.db.clone()); + nwc_manager.test_connection(connection_uri).await?; + } + + let connection = update_nwc_connection( + &state.db, + &connection_id, + &user_context.organization_id, + request, + ) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(connection)) +} + +pub async fn delete_nwc_connection_handler( + State(state): State>, + Extension(user_context): Extension, + Path(connection_id): Path, +) -> Result { + let deleted = + delete_nwc_connection(&state.db, &connection_id, &user_context.organization_id).await?; + + if deleted { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound) + } +} + +pub async fn test_nwc_connection_handler( + State(state): State>, + Extension(_user_context): Extension, + Json(request): Json, +) -> Result, AppError> { + let nwc_manager = NwcManager::new(state.db.clone()); + let success = nwc_manager.test_connection(&request.connection_uri).await?; + + Ok(Json(NwcTestResponse { + success, + methods: vec![], // Not available with current implementation + alias: None, // Not available with current implementation + })) +} + +pub async fn create_mint_auto_refill_handler( + State(state): State>, + Extension(user_context): Extension, + Json(request): Json, +) -> Result, AppError> { + let settings = + create_mint_auto_refill_settings(&state.db, &user_context.organization_id, request).await?; + + Ok(Json(settings)) +} + +pub async fn get_mint_auto_refill_settings_handler( + State(state): State>, + Extension(user_context): Extension, +) -> Result, AppError> { + let settings = + get_mint_auto_refill_settings_for_organization(&state.db, &user_context.organization_id) + .await?; + + Ok(Json(MintAutoRefillSettingsResponse { settings })) +} + +pub async fn get_mint_auto_refill_by_mint_handler( + State(state): State>, + Extension(user_context): Extension, + Path(mint_id): Path, +) -> Result, AppError> { + let settings = + get_mint_auto_refill_settings_by_mint(&state.db, mint_id, &user_context.organization_id) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(settings)) +} + +pub async fn update_mint_auto_refill_handler( + State(state): State>, + Extension(user_context): Extension, + Path(settings_id): Path, + Json(request): Json, +) -> Result, AppError> { + let settings = update_mint_auto_refill_settings( + &state.db, + &settings_id, + &user_context.organization_id, + request, + ) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(settings)) +} + +pub async fn delete_mint_auto_refill_handler( + State(state): State>, + Extension(user_context): Extension, + Path(settings_id): Path, +) -> Result { + let deleted = + delete_mint_auto_refill_settings(&state.db, &settings_id, &user_context.organization_id) + .await?; + + if deleted { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound) + } +} + +pub async fn pay_invoice_with_nwc_handler( + State(state): State>, + Extension(user_context): Extension, + Json(request): Json, +) -> Result, AppError> { + let nwc_manager = NwcManager::new(state.db.clone()); + let connection = + get_active_nwc_connection_for_organization(&state.db, &user_context.organization_id) + .await + .unwrap(); + + match nwc_manager + .pay_invoice_with_connection( + &state, + &connection.id, + &user_context.organization_id, + &request.invoice, + &request.quote_id, + &request.mint_url, + ) + .await + { + Ok(preimage) => Ok(Json(PayInvoiceResponse { + success: true, + preimage: Some(preimage), + error: None, + })), + Err(e) => Ok(Json(PayInvoiceResponse { + success: false, + preimage: None, + error: Some(e.to_string()), + })), + } +} diff --git a/crates/otrta/src/lib.rs b/crates/otrta/src/lib.rs index 9adf824..32f0456 100644 --- a/crates/otrta/src/lib.rs +++ b/crates/otrta/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod auto_refill_service; pub mod completion; pub mod db; pub mod error; @@ -6,6 +7,7 @@ pub mod handlers; pub mod models; pub mod multimint; pub mod multimint_manager; +pub mod nwc_client; pub mod onion; pub mod proxy; pub mod search; diff --git a/crates/otrta/src/multimint.rs b/crates/otrta/src/multimint.rs index 72a48e6..9abe755 100644 --- a/crates/otrta/src/multimint.rs +++ b/crates/otrta/src/multimint.rs @@ -129,6 +129,13 @@ impl MultimintWalletWrapper { Ok(balance) } + pub async fn get_balance_for_mint( + &self, + mint_url: &str, + ) -> Result> { + self.get_mint_balance(mint_url).await + } + pub async fn get_balances_by_units( &self, db: &crate::db::Pool, diff --git a/crates/otrta/src/nwc_client.rs b/crates/otrta/src/nwc_client.rs new file mode 100644 index 0000000..aaa9a99 --- /dev/null +++ b/crates/otrta/src/nwc_client.rs @@ -0,0 +1,263 @@ +use nostr::nips::nip47::{MakeInvoiceRequest, PayInvoiceRequest}; +use nwc::prelude::*; +use std::str::FromStr; +use std::sync::Arc; +use tokio::time::{sleep, timeout, Duration}; +use tracing::{debug, error, info, warn}; + +use crate::db::nwc::NwcConnection; +use crate::error::AppError; +use crate::handlers::check_lightning_payment_nwc; +use crate::models::AppState; + +pub struct NwcClient { + client: NWC, + connection_info: NwcConnection, +} + +impl NwcClient { + pub async fn new(connection: NwcConnection) -> Result { + let uri = NostrWalletConnectURI::from_str(&connection.connection_uri).map_err(|e| { + error!("Failed to parse NWC URI: {}", e); + AppError::BadRequest("Invalid NWC connection URI".to_string()) + })?; + + let monitor = Monitor::new(100); + let mut monitor_sub = monitor.subscribe(); + tokio::spawn(async move { + while let Ok(notification) = monitor_sub.recv().await { + println!("Notification: {notification:?}"); + } + }); + + let nwc: NWC = NWC::with_opts(uri, NostrWalletConnectOptions::default().monitor(monitor)); + + let nwc_client = Self { + client: nwc, + connection_info: connection, + }; + + Ok(nwc_client) + } + + pub async fn get_balance(&self) -> Result { + let balance = timeout(Duration::from_secs(10), self.client.get_balance()) + .await + .map_err(|_| { + error!("NWC get_balance timed out"); + AppError::InternalServerError + })? + .map_err(|e| { + error!("Failed to get balance from NWC wallet: {}", e); + AppError::InternalServerError + })?; + + Ok(balance) + } + + pub async fn request_payment( + &self, + amount_msat: u64, + description: &str, + ) -> Result { + let request = MakeInvoiceRequest { + amount: amount_msat, + description: Some(description.to_string()), + description_hash: None, + expiry: None, + }; + + let response = timeout(Duration::from_secs(30), self.client.make_invoice(request)) + .await + .map_err(|_| { + error!("NWC make_invoice timed out"); + AppError::InternalServerError + })? + .map_err(|e| { + error!("Failed to create invoice via NWC: {}", e); + AppError::InternalServerError + })?; + + info!("Created invoice via NWC: {} msat", amount_msat); + Ok(response.invoice) + } + + pub async fn pay_invoice(&self, invoice: &str) -> Result { + let request = PayInvoiceRequest::new(invoice); + + let response = timeout(Duration::from_secs(60), self.client.pay_invoice(request)) + .await + .map_err(|_| { + error!("NWC pay_invoice timed out"); + AppError::InternalServerError + })? + .map_err(|e| { + error!("Failed to pay invoice via NWC: {}", e); + AppError::InternalServerError + })?; + + info!("Paid invoice via NWC. Preimage: {}", response.preimage); + Ok(response.preimage) + } + + pub fn connection_name(&self) -> &str { + &self.connection_info.name + } + + pub fn connection_id(&self) -> &uuid::Uuid { + &self.connection_info.id + } +} + +pub struct NwcManager { + db_pool: sqlx::PgPool, +} + +impl NwcManager { + pub fn new(db_pool: sqlx::PgPool) -> Self { + Self { db_pool } + } + + pub async fn get_client_for_connection( + &self, + connection_id: &uuid::Uuid, + organization_id: &uuid::Uuid, + ) -> Result { + let connection = + crate::db::nwc::get_nwc_connection_by_id(&self.db_pool, connection_id, organization_id) + .await? + .ok_or_else(|| { + warn!("NWC connection not found: {}", connection_id); + AppError::NotFound + })?; + + println!("connection: {:?}", connection); + + if !connection.is_active { + warn!("NWC connection is inactive: {}", connection_id); + return Err(AppError::BadRequest( + "NWC connection is inactive".to_string(), + )); + } + + NwcClient::new(connection).await + } + + pub async fn test_connection(&self, connection_uri: &str) -> Result { + let uri = NostrWalletConnectURI::from_str(connection_uri).map_err(|e| { + error!("Failed to parse NWC URI for testing: {}", e); + AppError::BadRequest("Invalid NWC connection URI".to_string()) + })?; + + let monitor = Monitor::new(100); + let mut monitor_sub = monitor.subscribe(); + + let _nwc: NWC = NWC::with_opts( + uri.clone(), + NostrWalletConnectOptions::default().monitor(monitor), + ); + + let connection_result = timeout(Duration::from_secs(10), async { + while let Ok(notification) = monitor_sub.recv().await { + debug!("Notification: {notification:?}"); + let notification_str = format!("{:?}", notification); + if notification_str.contains("status: Connected") { + return Ok::(true); + } else if notification_str.contains("status: Disconnected") { + return Ok::(false); + } + } + Ok::(false) + }) + .await; + + match connection_result { + Ok(Ok(connected)) => { + if connected { + info!("NWC connection test successful"); + } else { + warn!("NWC connection test failed - not connected"); + } + Ok(connected) + } + Ok(Err(_)) => { + error!("Error during NWC connection test"); + Ok(false) + } + Err(_) => { + error!("NWC connection test timed out"); + Ok(false) + } + } + } + + pub async fn request_mint_refill( + &self, + nwc_connection_id: &uuid::Uuid, + organization_id: &uuid::Uuid, + amount_msat: u64, + mint_url: &str, + ) -> Result { + let client = self + .get_client_for_connection(nwc_connection_id, organization_id) + .await?; + + let description = format!("Auto-refill for mint: {} ({}msat)", mint_url, amount_msat); + client.request_payment(amount_msat, &description).await + } + + pub async fn pay_invoice_with_connection( + &self, + state: &Arc, + nwc_connection_id: &uuid::Uuid, + organization_id: &uuid::Uuid, + invoice: &str, + quote_id: &str, + mint_url: &str, + ) -> Result { + let connection = crate::db::nwc::get_nwc_connection_by_id( + &self.db_pool, + nwc_connection_id, + organization_id, + ) + .await? + .ok_or_else(|| { + warn!("NWC connection not found: {}", nwc_connection_id); + AppError::NotFound + })?; + + println!("{:?}", connection); + let uri = NostrWalletConnectURI::from_str(&connection.connection_uri).map_err(|e| { + error!("Failed to parse NWC URI for testing: {}", e); + AppError::BadRequest("Invalid NWC connection URI".to_string()) + })?; + + let monitor = Monitor::new(100); + + let mut monitor_sub = monitor.subscribe(); + tokio::spawn(async move { + while let Ok(notification) = monitor_sub.recv().await { + println!("Notification: {notification:?}"); + } + }); + + let nwc: NWC = NWC::with_opts( + uri.clone(), + NostrWalletConnectOptions::default().monitor(monitor), + ); + let request: PayInvoiceRequest = PayInvoiceRequest::new(invoice); + let _ = nwc.pay_invoice(request).await.unwrap(); + + for _ in [..10] { + let response = check_lightning_payment_nwc(state, organization_id, quote_id, mint_url) + .await + .unwrap(); + if response.state == "PAID" { + return Ok(response.state.clone()); + } + sleep(Duration::from_secs(2)).await; + } + + Err(AppError::NotFound) + } +} diff --git a/ui/app/settings/page.tsx b/ui/app/settings/page.tsx index a74d06a..7727800 100644 --- a/ui/app/settings/page.tsx +++ b/ui/app/settings/page.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { ApiKeySettings } from '@/components/settings/api-key-settings'; +import { NwcSettings } from '@/components/settings/nwc-settings'; import { SiteHeader } from '@/components/site-header'; import { AppSidebar } from '@/components/app-sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; @@ -21,12 +22,16 @@ export default function SettingsPage() {
- + API Keys + NWC + + +
diff --git a/ui/components/mint-management-page.tsx b/ui/components/mint-management-page.tsx index 6751080..d6b2838 100644 --- a/ui/components/mint-management-page.tsx +++ b/ui/components/mint-management-page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Send, Zap, Activity } from 'lucide-react'; +import { Send, Zap, Activity, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { MintList } from './mint-list'; import { CollectSats } from './collect-sats'; @@ -17,6 +17,7 @@ import { type CreateInvoiceResponse, } from '@/lib/api/services/lightning'; import { MintService } from '@/lib/api/services/mints'; +import { NwcService } from '@/lib/api/services/nwc'; import { Card, CardContent, @@ -28,6 +29,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, @@ -62,12 +64,22 @@ export function MintManagementPage() { token: undefined, }); const [selectedUnit, setSelectedUnit] = useState('sat'); + const [useNwc, setUseNwc] = useState(false); + const [selectedNwcConnectionId, setSelectedNwcConnectionId] = + useState(''); const { data: activeMintsWithUnits } = useQuery({ queryKey: ['active-mints-with-units'], queryFn: () => MintService.getActiveMintsWithUnits(), }); + const { data: nwcConnections = [] } = useQuery({ + queryKey: ['nwc-connections'], + queryFn: () => NwcService.listConnections(), + }); + + const activeNwcConnections = nwcConnections.filter((conn) => conn.is_active); + // Legacy support - convert mints with units to simple mints for backward compatibility // Also deduplicate by mint_url to avoid duplicates in dropdown const activeMints = activeMintsWithUnits @@ -137,10 +149,19 @@ export function MintManagementPage() { const lightningInvoiceMutation = useMutation({ mutationFn: (data: TopupRequest) => LightningService.createInvoice(data), onSuccess: (response) => { - toast.success('Lightning invoice created!'); - setCurrentInvoice(response); - setTopupDialogOpen(false); - setLightningInvoiceModalOpen(true); + if (useNwc && selectedNwcConnectionId) { + toast.success('Lightning invoice created! Paying with NWC...'); + nwcPayInvoiceMutation.mutate({ + invoice: response.payment_request, + quote_id: response.quote_id, + mint_url: response.mint_url, + }); + } else { + toast.success('Lightning invoice created!'); + setCurrentInvoice(response); + setTopupDialogOpen(false); + setLightningInvoiceModalOpen(true); + } }, onError: (error) => { console.error('Lightning invoice error:', error); @@ -148,6 +169,68 @@ export function MintManagementPage() { }, }); + const nwcPayInvoiceMutation = useMutation({ + mutationFn: async ({ + invoice, + quote_id, + mint_url, + }: { + invoice: string; + quote_id: string; + mint_url: string; + }) => { + const paymentResult = await NwcService.payInvoice({ + invoice, + quote_id, + mint_url, + }); + if (!paymentResult.success) { + throw new Error(paymentResult.error || 'Payment failed'); + } + return paymentResult; + }, + onSuccess: () => { + toast.success('Invoice paid successfully!'); + handlePaymentComplete(); + setTopupDialogOpen(false); + }, + onError: (error) => { + console.error('NWC payment error:', error); + toast.error(`NWC payment failed: ${error.message}`); + }, + }); + + const nwcCreateAndPayMutation = useMutation({ + mutationFn: async ({ + lightningRequest, + }: { + lightningRequest: TopupRequest; + }) => { + const invoice = await LightningService.createInvoice(lightningRequest); + console.log(invoice); + const paymentResult = await NwcService.payInvoice({ + invoice: invoice.payment_request, + quote_id: invoice.quote_id, + mint_url: invoice.mint_url, + }); + + if (!paymentResult.success) { + throw new Error(paymentResult.error || 'Payment failed'); + } + + return paymentResult; + }, + onSuccess: () => { + toast.success('Invoice paid successfully!'); + handlePaymentComplete(); + setTopupDialogOpen(false); + }, + onError: (error) => { + console.error('NWC payment error:', error); + toast.error(`NWC payment failed: ${error.message}`); + }, + }); + const resetForms = () => { setTopupForm({ mint_url: '', @@ -156,6 +239,8 @@ export function MintManagementPage() { token: undefined, }); setSelectedUnit('sat'); + setUseNwc(false); + setSelectedNwcConnectionId(''); }; const handleTopup = () => { @@ -164,13 +249,23 @@ export function MintManagementPage() { topupForm.amount && topupForm.mint_url ) { - const lightningRequest: TopupRequest = { - amount: topupForm.amount, - unit: validUnit, - mint_url: topupForm.mint_url, - // description: 'Multimint Lightning Topup', - }; - lightningInvoiceMutation.mutate(lightningRequest); + if (useNwc) { + const lightningRequest: TopupRequest = { + amount: topupForm.amount, + unit: validUnit, + mint_url: topupForm.mint_url, + }; + nwcCreateAndPayMutation.mutate({ + lightningRequest, + }); + } else { + const lightningRequest: TopupRequest = { + amount: topupForm.amount, + unit: validUnit, + mint_url: topupForm.mint_url, + }; + lightningInvoiceMutation.mutate(lightningRequest); + } } else { topupMutation.mutate(topupForm); } @@ -192,6 +287,7 @@ export function MintManagementPage() { const isLightningProcessing = lightningInvoiceMutation.isPending; const isTopupProcessing = topupMutation.isPending; + const isNwcProcessing = nwcCreateAndPayMutation.isPending; return (
@@ -317,6 +413,34 @@ export function MintManagementPage() {

Lightning invoices will be generated for this amount

+ +
+ { + setUseNwc(checked as boolean); + if (checked && activeNwcConnections.length > 0) { + setSelectedNwcConnectionId( + activeNwcConnections[0].id + ); + } else { + setSelectedNwcConnectionId(''); + } + }} + /> + +
)} @@ -356,17 +480,26 @@ export function MintManagementPage() { disabled={ isLightningProcessing || isTopupProcessing || - !topupForm.mint_url || - (topupForm.method === 'lightning' && !topupForm.amount) || - (topupForm.method === 'ecash' && !topupForm.token) + isNwcProcessing } > - {isLightningProcessing || isTopupProcessing - ? topupForm.method === 'lightning' - ? 'Creating Invoice...' - : 'Processing...' + {(isLightningProcessing || + isTopupProcessing || + isNwcProcessing) && ( + + )} + {isLightningProcessing || + isTopupProcessing || + isNwcProcessing + ? isNwcProcessing + ? 'Paying with NWC...' + : topupForm.method === 'lightning' + ? 'Creating Invoice...' + : 'Processing...' : topupForm.method === 'lightning' - ? 'Create Lightning Invoice' + ? useNwc + ? 'Create & Pay Invoice' + : 'Create Lightning Invoice' : 'Topup'} diff --git a/ui/components/settings/nwc-settings.tsx b/ui/components/settings/nwc-settings.tsx new file mode 100644 index 0000000..29117a0 --- /dev/null +++ b/ui/components/settings/nwc-settings.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Zap, + Plus, + Trash2, + Eye, + EyeOff, + RefreshCw, + Wifi, + WifiOff, + TestTube, + Copy, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Switch } from '@/components/ui/switch'; +import { NwcService } from '@/lib/api/services/nwc'; +import { + NwcConnection, + CreateNwcConnection, + UpdateNwcConnection, + NwcTestResponse, +} from '@/lib/api/schemas/nwc'; + +export function NwcSettings() { + const [connections, setConnections] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [showConnectionUri, setShowConnectionUri] = useState<{ + [key: string]: boolean; + }>({}); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + + // Create connection form state + const [newConnectionName, setNewConnectionName] = useState(''); + const [newConnectionUri, setNewConnectionUri] = useState(''); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [testResult, setTestResult] = useState(null); + + const fetchConnections = async () => { + try { + const data = await NwcService.listConnections(); + setConnections(data); + } catch (error) { + console.error('Error fetching NWC connections:', error); + toast.error('Failed to load NWC connections'); + } + }; + + const testConnection = async () => { + if (!newConnectionUri.trim()) { + toast.error('Please enter a connection URI'); + return; + } + + setIsTestingConnection(true); + try { + const result = await NwcService.testConnection({ + connection_uri: newConnectionUri, + }); + setTestResult(result); + toast.success('Connection test successful!'); + } catch (error) { + console.error('Error testing connection:', error); + setTestResult(null); + toast.error('Connection test failed'); + } finally { + setIsTestingConnection(false); + } + }; + + const createConnection = async () => { + if (!newConnectionName.trim() || !newConnectionUri.trim()) { + toast.error('Please provide both name and connection URI'); + return; + } + + setIsLoading(true); + try { + const createRequest: CreateNwcConnection = { + name: newConnectionName, + connection_uri: newConnectionUri, + }; + + const newConnection = await NwcService.createConnection(createRequest); + setConnections([...connections, newConnection]); + setIsCreateDialogOpen(false); + setNewConnectionName(''); + setNewConnectionUri(''); + setTestResult(null); + toast.success('NWC connection created successfully'); + } catch (error) { + console.error('Error creating NWC connection:', error); + toast.error( + error instanceof Error + ? error.message + : 'Failed to create NWC connection' + ); + } finally { + setIsLoading(false); + } + }; + + const deleteConnection = async (id: string) => { + if (!confirm('Are you sure you want to delete this NWC connection?')) + return; + + setIsLoading(true); + try { + await NwcService.deleteConnection(id); + setConnections(connections.filter((conn) => conn.id !== id)); + toast.success('NWC connection deleted successfully'); + } catch (error) { + console.error('Error deleting NWC connection:', error); + toast.error( + error instanceof Error + ? error.message + : 'Failed to delete NWC connection' + ); + } finally { + setIsLoading(false); + } + }; + + const toggleConnectionVisibility = (id: string) => { + setShowConnectionUri((prev) => ({ + ...prev, + [id]: !prev[id], + })); + }; + + const updateConnectionStatus = async (id: string, isActive: boolean) => { + setIsLoading(true); + try { + const updateRequest: UpdateNwcConnection = { is_active: isActive }; + const updatedConnection = await NwcService.updateConnection( + id, + updateRequest + ); + setConnections( + connections.map((conn) => (conn.id === id ? updatedConnection : conn)) + ); + toast.success( + isActive ? 'Connection activated' : 'Connection deactivated' + ); + } catch (error) { + console.error('Error updating connection:', error); + toast.error( + error instanceof Error ? error.message : 'Failed to update connection' + ); + } finally { + setIsLoading(false); + } + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + } catch { + toast.error('Failed to copy to clipboard'); + } + }; + + const formatDate = (dateString: string | undefined) => { + if (!dateString) return 'Never'; + return new Date(dateString).toLocaleDateString(); + }; + + useEffect(() => { + const loadData = async () => { + setIsLoading(true); + await fetchConnections(); + setIsLoading(false); + }; + loadData(); + }, []); + + return ( +
+ + +
+
+ + + NWC Connections + + + Manage Nostr Wallet Connect connections for lightning payments + +
+ {connections.length === 0 && ( + + + + + + + Create NWC Connection + +
+
+ + setNewConnectionName(e.target.value)} + placeholder='Enter connection name' + /> +
+
+ + setNewConnectionUri(e.target.value)} + placeholder='nostr+walletconnect://...' + /> +
+
+ +
+ {testResult && ( + + + + Connection successful! Methods:{' '} + {testResult.methods.join(', ')} + {testResult.alias && ` | Alias: ${testResult.alias}`} + + + )} +
+ + +
+
+
+
+ )} +
+
+ + {isLoading && connections.length === 0 ? ( +
+ + Loading connections... +
+ ) : connections.length === 0 ? ( +
+ +

No NWC connections found

+

+ Create your first connection to enable lightning payments +

+
+ ) : ( +
+ {connections.map((connection) => ( + + +
+
+
+

+ {connection.name} +

+
+ + {connection.is_active ? ( + <> + + Active + + ) : ( + <> + + Inactive + + )} + +
+
+ +
+
+ Created:{' '} + {formatDate(connection.created_at)} +
+
+ Updated:{' '} + {formatDate(connection.updated_at)} +
+
+ +
+
+ + {showConnectionUri[connection.id] + ? connection.connection_uri + : '••••••••••••••••••••••••••••••••'} + +
+
+ + +
+
+
+ +
+
+
+ + updateConnectionStatus(connection.id, checked) + } + disabled={isLoading} + /> + + {connection.is_active ? 'Active' : 'Inactive'} + +
+ +
+
+
+
+
+ ))} +
+ )} +
+
+ + + + + NWC (Nostr Wallet Connect) allows secure lightning payments through + Nostr protocols. Connect your wallet to enable lightning invoice + payments. + + +
+ ); +} diff --git a/ui/lib/api/schemas/nwc.ts b/ui/lib/api/schemas/nwc.ts new file mode 100644 index 0000000..a4a71c1 --- /dev/null +++ b/ui/lib/api/schemas/nwc.ts @@ -0,0 +1,42 @@ +export interface NwcConnection { + id: string; + organization_id: string; + name: string; + connection_uri: string; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateNwcConnection { + name: string; + connection_uri: string; +} + +export interface UpdateNwcConnection { + name?: string; + connection_uri?: string; + is_active?: boolean; +} + +export interface NwcTestRequest { + connection_uri: string; +} + +export interface NwcTestResponse { + success: boolean; + methods: string[]; + alias?: string; +} + +export interface PayInvoiceRequest { + invoice: string; + quote_id: string; + mint_url: string; +} + +export interface PayInvoiceResponse { + success: boolean; + preimage?: string; + error?: string; +} diff --git a/ui/lib/api/services/mints.ts b/ui/lib/api/services/mints.ts index 029e1c8..77e60fb 100644 --- a/ui/lib/api/services/mints.ts +++ b/ui/lib/api/services/mints.ts @@ -89,6 +89,17 @@ export class MintService { } } + // Get all mints (alias for getAllMints for compatibility) + static async listMints(): Promise { + try { + const response = await this.getAllMints(); + return response.mints; + } catch (error) { + console.error('Error fetching mints:', error); + throw error; + } + } + // Get active mints only static async getActiveMints(): Promise { try { diff --git a/ui/lib/api/services/nwc.ts b/ui/lib/api/services/nwc.ts new file mode 100644 index 0000000..93161d9 --- /dev/null +++ b/ui/lib/api/services/nwc.ts @@ -0,0 +1,94 @@ +import { apiClient } from '../client'; +import { + NwcConnection, + CreateNwcConnection, + UpdateNwcConnection, + NwcTestRequest, + NwcTestResponse, + PayInvoiceRequest, + PayInvoiceResponse, +} from '../schemas/nwc'; + +interface NwcConnectionsResponse { + connections: NwcConnection[]; +} + +export class NwcService { + // NWC Connections + static async listConnections(): Promise { + try { + const response = await apiClient.get( + '/api/nwc/connections' + ); + return response.connections; + } catch (error) { + console.error('Error fetching NWC connections:', error); + throw error; + } + } + + static async createConnection( + data: CreateNwcConnection + ): Promise { + try { + return await apiClient.post( + '/api/nwc/connections', + data as unknown as Record + ); + } catch (error) { + console.error('Error creating NWC connection:', error); + throw error; + } + } + + static async updateConnection( + connectionId: string, + data: UpdateNwcConnection + ): Promise { + try { + return await apiClient.put( + `/api/nwc/connections/${connectionId}`, + data as unknown as Record + ); + } catch (error) { + console.error(`Error updating NWC connection ${connectionId}:`, error); + throw error; + } + } + + static async deleteConnection(connectionId: string): Promise { + try { + await apiClient.delete(`/api/nwc/connections/${connectionId}`); + } catch (error) { + console.error(`Error deleting NWC connection ${connectionId}:`, error); + throw error; + } + } + + static async testConnection(data: NwcTestRequest): Promise { + try { + return await apiClient.post( + '/api/nwc/test', + data as unknown as Record + ); + } catch (error) { + console.error('Error testing NWC connection:', error); + throw error; + } + } + + // Invoice payment + static async payInvoice( + data: PayInvoiceRequest + ): Promise { + try { + return await apiClient.post( + `/api/nwc/connections/pay`, + data as unknown as Record + ); + } catch (error) { + console.error(`Error paying invoice:`, error); + throw error; + } + } +}