tsk пре 1 недеља
родитељ
комит
e157ab581a

+ 24 - 0
Cargo.lock

@@ -1168,6 +1168,7 @@ dependencies = [
  "cbor-diag",
  "cdk-common",
  "cdk-fake-wallet",
+ "cdk-npubcash",
  "cdk-prometheus",
  "cdk-signatory",
  "cdk-sqlite",
@@ -1523,6 +1524,29 @@ dependencies = [
 ]
 
 [[package]]
+name = "cdk-npubcash"
+version = "0.14.0"
+dependencies = [
+ "async-trait",
+ "base64 0.22.1",
+ "cashu",
+ "cdk",
+ "cdk-common",
+ "cdk-sqlite",
+ "chrono",
+ "nostr-sdk",
+ "reqwest",
+ "rustls 0.23.35",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.17",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+]
+
+[[package]]
 name = "cdk-payment-processor"
 version = "0.14.0"
 dependencies = [

+ 1 - 0
Cargo.toml

@@ -65,6 +65,7 @@ cdk-postgres = { path = "./crates/cdk-postgres", default-features = true, versio
 cdk-signatory = { path = "./crates/cdk-signatory", version = "=0.14.0", default-features = false }
 cdk-mintd = { path = "./crates/cdk-mintd", version = "=0.14.0", default-features = false }
 cdk-prometheus = { path = "./crates/cdk-prometheus", version = "=0.14.0", default-features = false }
+cdk-npubcash = { path = "./crates/cdk-npubcash", version = "=0.14.0" }
 clap = { version = "4.5.31", features = ["derive"] }
 ciborium = { version = "0.2.2", default-features = false, features = ["std"] }
 cbor-diag = "0.1.12"

+ 2 - 1
crates/cdk-cli/Cargo.toml

@@ -11,11 +11,12 @@ rust-version.workspace = true
 readme = "README.md"
 
 [features]
-default = []
+default = ["npubcash"]
 sqlcipher = ["cdk-sqlite/sqlcipher"]
 # MSRV is not tracked with redb enabled
 redb = ["dep:cdk-redb"]
 tor = ["cdk/tor"]
+npubcash = ["cdk/npubcash"]
 
 [dependencies]
 anyhow.workspace = true

+ 94 - 0
crates/cdk-cli/README.md

@@ -16,6 +16,7 @@ A command-line Cashu wallet implementation built with the Cashu Development Kit
 - **Lightning Integration**: Pay Lightning invoices (BOLT11, BOLT12, BIP353) and receive payments
 - **Payment Requests**: Create and pay payment requests with various conditions (P2PK, HTLC)
 - **Token Transfer**: Transfer tokens between different mints
+- **NpubCash Integration**: Receive ecash via Nostr public key addresses (npub@npubx.cash)
 - **Multi-Currency Support**: Support for different currency units (sat, usd, eur, etc.)
 - **Database Options**: SQLite or Redb backend with optional encryption (SQLCipher)
 - **Tor Support**: Built-in Tor transport support (when compiled with feature)
@@ -36,6 +37,9 @@ cargo build --bin cdk-cli --release
 
 ### Build with Optional Features
 ```bash
+# Default build (includes npubcash)
+cargo build --bin cdk-cli --release
+
 # With Tor support
 cargo build --bin cdk-cli --release --features tor
 
@@ -44,6 +48,9 @@ cargo build --bin cdk-cli --release --features sqlcipher
 
 # With Redb database
 cargo build --bin cdk-cli --release --features redb
+
+# Without npubcash (if you want to exclude it)
+cargo build --bin cdk-cli --release --no-default-features --features <other-features>
 ```
 
 ## Quick Start
@@ -294,6 +301,93 @@ cdk-cli cat-login --username <username> --password <password>
 cdk-cli cat-device-login
 ```
 
+#### NpubCash Integration
+
+> **Note**: Requires building with `--features npubcash` (enabled by default)
+
+NpubCash allows you to receive ecash payments via a Nostr public key address (npub@npubx.cash). The CLI automatically derives Nostr keys from your wallet seed for authentication.
+
+```bash
+# Set custom NpubCash server URL (optional, defaults to https://npubx.cash)
+cdk-cli --npubcash-url https://npubx.cash npub-cash <SUBCOMMANDS>
+```
+
+**Show Your NpubCash Address and Keys**
+
+```bash
+# Display your npub address and authentication keys
+cdk-cli npub-cash --mint-url <MINT_URL> show-keys
+
+# Example output shows:
+# - Your public key (npub) and npub.cash address (npub@npubx.cash)
+# - Your secret key (keep this safe!)
+```
+
+**Sync Quotes from NpubCash**
+
+```bash
+# Fetch all quotes from the NpubCash server and add to wallet
+cdk-cli npub-cash --mint-url <MINT_URL> sync
+```
+
+**List Quotes**
+
+```bash
+# List all quotes
+cdk-cli npub-cash --mint-url <MINT_URL> list
+
+# List quotes since a specific Unix timestamp
+cdk-cli npub-cash --mint-url <MINT_URL> list --since 1700000000
+
+# Output as JSON
+cdk-cli npub-cash --mint-url <MINT_URL> list --format json
+```
+
+**Subscribe to Quote Updates**
+
+```bash
+# Monitor for new quotes and automatically mint paid ones
+cdk-cli npub-cash --mint-url <MINT_URL> subscribe
+
+# Subscribe without auto-minting (just display quotes)
+cdk-cli npub-cash --mint-url <MINT_URL> subscribe --auto-mint false
+```
+
+The subscribe command:
+- Polls the NpubCash server every 5 seconds for new quotes
+- Automatically adds quotes to your wallet database
+- Optionally auto-mints paid quotes into your wallet
+- Press Ctrl+C to stop
+
+**Configure Mint URL on Server**
+
+```bash
+# Set which mint URL the NpubCash server should use for your quotes
+cdk-cli npub-cash --mint-url <MINT_URL> set-mint https://mint.example.com
+```
+
+This tells the NpubCash server which mint to use when creating quotes for payments to your npub address.
+
+**Complete NpubCash Workflow Example**
+
+```bash
+# 1. Show your NpubCash address
+cdk-cli npub-cash -m http://127.0.0.1:8085 show-keys
+# Share your npub@npubx.cash address with senders
+
+# 2. Subscribe to incoming payments
+cdk-cli npub-cash -m http://127.0.0.1:8085 subscribe
+# Leave this running to automatically receive and mint payments
+
+# 3. In another terminal, check your balance
+cdk-cli balance
+
+# 4. When someone sends sats to your npub address:
+#    - The subscribe command will show the new quote
+#    - If paid, tokens are automatically minted
+#    - Your balance increases
+```
+
 ## Configuration
 
 ### Storage Location

+ 23 - 0
crates/cdk-cli/src/main.rs

@@ -58,6 +58,10 @@ struct Cli {
     /// Currency unit to use for the wallet
     #[arg(short, long, default_value = "sat")]
     unit: String,
+    /// NpubCash API URL
+    #[cfg(feature = "npubcash")]
+    #[arg(long, default_value = "https://npubx.cash")]
+    npubcash_url: String,
     /// Use Tor transport (only when built with --features tor). Defaults to 'on' when feature is enabled.
     #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
     #[arg(long = "tor", value_enum, default_value_t = TorToggle::On)]
@@ -109,6 +113,15 @@ enum Commands {
     CatLogin(sub_commands::cat_login::CatLoginSubCommand),
     /// Cat login with device code flow
     CatDeviceLogin(sub_commands::cat_device_login::CatDeviceLoginSubCommand),
+    /// NpubCash integration commands
+    #[cfg(feature = "npubcash")]
+    NpubCash {
+        /// Mint URL to use for npubcash operations
+        #[arg(short, long)]
+        mint_url: String,
+        #[command(subcommand)]
+        command: sub_commands::npubcash::NpubCashSubCommand,
+    },
 }
 
 #[tokio::main]
@@ -300,5 +313,15 @@ async fn main() -> Result<()> {
             )
             .await
         }
+        #[cfg(feature = "npubcash")]
+        Commands::NpubCash { mint_url, command } => {
+            sub_commands::npubcash::npubcash(
+                &multi_mint_wallet,
+                mint_url,
+                command,
+                Some(args.npubcash_url.clone()),
+            )
+            .await
+        }
     }
 }

+ 2 - 0
crates/cdk-cli/src/sub_commands/mod.rs

@@ -11,6 +11,8 @@ pub mod melt;
 pub mod mint;
 pub mod mint_blind_auth;
 pub mod mint_info;
+#[cfg(feature = "npubcash")]
+pub mod npubcash;
 pub mod pay_request;
 pub mod pending_mints;
 pub mod receive;

+ 330 - 0
crates/cdk-cli/src/sub_commands/npubcash.rs

@@ -0,0 +1,330 @@
+use std::str::FromStr;
+use std::time::Duration;
+
+use anyhow::{bail, Result};
+use cdk::amount::SplitTarget;
+use cdk::mint_url::MintUrl;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::wallet::{MultiMintWallet, Wallet};
+use cdk::StreamExt;
+use clap::Subcommand;
+use nostr_sdk::ToBech32;
+
+/// Helper function to get wallet for a specific mint URL
+async fn get_wallet_for_mint(
+    multi_mint_wallet: &MultiMintWallet,
+    mint_url_str: &str,
+) -> Result<Wallet> {
+    let mint_url = MintUrl::from_str(mint_url_str)?;
+
+    // Check if wallet exists for this mint
+    if !multi_mint_wallet.has_mint(&mint_url).await {
+        // Add the mint to the wallet
+        multi_mint_wallet.add_mint(mint_url.clone()).await?;
+    }
+
+    multi_mint_wallet
+        .get_wallet(&mint_url)
+        .await
+        .ok_or_else(|| anyhow::anyhow!("Failed to get wallet for mint: {}", mint_url_str))
+}
+
+#[derive(Subcommand)]
+pub enum NpubCashSubCommand {
+    /// Sync quotes from NpubCash
+    Sync,
+    /// List all quotes
+    List {
+        /// Only show quotes since this Unix timestamp
+        #[arg(long)]
+        since: Option<u64>,
+        /// Output format (table/json)
+        #[arg(long, default_value = "table")]
+        format: String,
+    },
+    /// Subscribe to quote updates and auto-mint paid quotes
+    Subscribe,
+    /// Set mint URL for NpubCash
+    SetMint {
+        /// The mint URL to use
+        url: String,
+    },
+    /// Show Nostr keys used for NpubCash authentication
+    ShowKeys,
+}
+
+pub async fn npubcash(
+    multi_mint_wallet: &MultiMintWallet,
+    mint_url: &str,
+    sub_command: &NpubCashSubCommand,
+    npubcash_url: Option<String>,
+) -> Result<()> {
+    // Get default npubcash URL if not provided
+    let base_url = npubcash_url.unwrap_or_else(|| "https://npubx.cash".to_string());
+
+    match sub_command {
+        NpubCashSubCommand::Sync => sync(multi_mint_wallet, mint_url, &base_url).await,
+        NpubCashSubCommand::List { since, format } => {
+            list(multi_mint_wallet, mint_url, &base_url, *since, format).await
+        }
+        NpubCashSubCommand::Subscribe => subscribe(multi_mint_wallet, mint_url, &base_url).await,
+        NpubCashSubCommand::SetMint { url } => {
+            set_mint(multi_mint_wallet, mint_url, &base_url, url).await
+        }
+        NpubCashSubCommand::ShowKeys => show_keys(multi_mint_wallet, mint_url).await,
+    }
+}
+
+/// Helper function to ensure active mint consistency
+async fn ensure_active_mint(multi_mint_wallet: &MultiMintWallet, mint_url: &str) -> Result<()> {
+    let mint_url_struct = MintUrl::from_str(mint_url)?;
+
+    match multi_mint_wallet.get_active_npubcash_mint().await? {
+        Some(active_mint) => {
+            if active_mint != mint_url_struct {
+                bail!(
+                    "Active NpubCash mint mismatch!\n\
+                    Current active mint: {}\n\
+                    Requested mint: {}\n\n\
+                    You can only have one active mint for NpubCash at a time.\n\
+                    Use 'set-mint' command to switch active mint.",
+                    active_mint,
+                    mint_url
+                );
+            }
+        }
+        None => {
+            // No active mint set, set this one as active
+            multi_mint_wallet
+                .set_active_npubcash_mint(mint_url_struct)
+                .await?;
+            println!("✓ Set {} as active NpubCash mint", mint_url);
+        }
+    }
+    Ok(())
+}
+
+async fn sync(multi_mint_wallet: &MultiMintWallet, mint_url: &str, base_url: &str) -> Result<()> {
+    ensure_active_mint(multi_mint_wallet, mint_url).await?;
+
+    println!("Syncing quotes from NpubCash...");
+
+    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+
+    // Enable NpubCash if not already enabled
+    wallet.enable_npubcash(base_url.to_string()).await?;
+
+    let quotes = wallet.sync_npubcash_quotes().await?;
+
+    println!("✓ Synced {} quotes successfully", quotes.len());
+    Ok(())
+}
+
+async fn list(
+    multi_mint_wallet: &MultiMintWallet,
+    mint_url: &str,
+    base_url: &str,
+    since: Option<u64>,
+    format: &str,
+) -> Result<()> {
+    ensure_active_mint(multi_mint_wallet, mint_url).await?;
+
+    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+
+    // Enable NpubCash if not already enabled
+    wallet.enable_npubcash(base_url.to_string()).await?;
+
+    let quotes = if let Some(since_ts) = since {
+        wallet.sync_npubcash_quotes_since(since_ts).await?
+    } else {
+        wallet.sync_npubcash_quotes().await?
+    };
+
+    match format {
+        "json" => {
+            let json = serde_json::to_string_pretty(&quotes)?;
+            println!("{}", json);
+        }
+        "table" => {
+            if quotes.is_empty() {
+                println!("No quotes found");
+            } else {
+                println!("\nQuotes:");
+                println!("{:-<80}", "");
+                for (i, quote) in quotes.iter().enumerate() {
+                    println!("{}. ID: {}", i + 1, quote.id);
+                    let amount_str = quote
+                        .amount
+                        .map_or("unknown".to_string(), |a| a.to_string());
+                    println!("   Amount: {} {}", amount_str, quote.unit);
+                    println!("{:-<80}", "");
+                }
+                println!("\nTotal: {} quotes", quotes.len());
+            }
+        }
+        _ => bail!("Invalid format '{}'. Use 'table' or 'json'", format),
+    }
+
+    Ok(())
+}
+
+async fn subscribe(
+    multi_mint_wallet: &MultiMintWallet,
+    mint_url: &str,
+    base_url: &str,
+) -> Result<()> {
+    ensure_active_mint(multi_mint_wallet, mint_url).await?;
+
+    println!("=== NpubCash Quote Subscription ===\n");
+
+    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+
+    // Enable NpubCash if not already enabled
+    wallet.enable_npubcash(base_url.to_string()).await?;
+    println!("✓ NpubCash integration enabled\n");
+
+    // Display the npub.cash address
+    let keys = wallet.get_npubcash_keys()?;
+    let display_url = base_url
+        .trim_start_matches("https://")
+        .trim_start_matches("http://");
+    println!("Your npub.cash address:");
+    println!("   {}@{}\n", keys.public_key().to_bech32()?, display_url);
+    println!("Send sats to this address to see them appear!\n");
+
+    println!("Auto-mint is ENABLED - paid quotes will be automatically minted\n");
+
+    println!("Starting quote polling...");
+    println!("Press Ctrl+C to stop.\n");
+
+    // Run polling and wait for Ctrl+C
+    let mut stream =
+        wallet.npubcash_proof_stream(SplitTarget::default(), None, Duration::from_secs(5));
+
+    tokio::select! {
+        _ = async {
+            while let Some(result) = stream.next().await {
+                match result {
+                    Ok((quote, proofs)) => {
+                        let amount_str = quote.amount.map_or("unknown".to_string(), |a| a.to_string());
+                        println!("Received payment for quote {}", quote.id);
+                        println!("  ├─ Amount: {} {}", amount_str, quote.unit);
+
+                        match proofs.total_amount() {
+                            Ok(amount) => {
+                                println!("  └─ Successfully minted {} sats!", amount);
+                                if let Ok(balance) = wallet.total_balance().await {
+                                    println!("     Wallet balance: {} sats", balance);
+                                }
+                            }
+                            Err(e) => println!("  └─ Failed to calculate amount: {}", e),
+                        }
+                        println!();
+                    }
+                    Err(e) => {
+                        println!("Error processing payment: {}", e);
+                    }
+                }
+            }
+        } => {}
+        _ = tokio::signal::ctrl_c() => {
+            println!("\nStopping quote polling...");
+        }
+    }
+
+    // Show final wallet balance
+    let balance = wallet.total_balance().await?;
+    println!("Final wallet balance: {} sats\n", balance);
+
+    Ok(())
+}
+
+async fn set_mint(
+    multi_mint_wallet: &MultiMintWallet,
+    mint_url: &str,
+    base_url: &str,
+    url: &str,
+) -> Result<()> {
+    println!("Setting NpubCash mint URL to: {}", url);
+
+    // Update active mint in KV store
+    let mint_url_struct = MintUrl::from_str(mint_url)?;
+    multi_mint_wallet
+        .set_active_npubcash_mint(mint_url_struct)
+        .await?;
+
+    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+
+    // Enable NpubCash if not already enabled
+    wallet.enable_npubcash(base_url.to_string()).await?;
+
+    // Try to set the mint URL on the NpubCash server
+    match wallet.set_npubcash_mint_url(url).await {
+        Ok(_) => {
+            println!("✓ Mint URL updated successfully on NpubCash server");
+            println!("\nThe NpubCash server will now include this mint URL");
+            println!("when creating quotes for your npub address.");
+        }
+        Err(e) => {
+            let error_msg = e.to_string();
+
+            // Check if the error is a 404 (endpoint not supported)
+            if error_msg.contains("API error (404)") {
+                println!("⚠️  Warning: NpubCash server does not support setting mint URL");
+                println!("\nThis means:");
+                println!(
+                    "  • The server at '{}' does not have the settings endpoint",
+                    base_url
+                );
+                println!("  • Quotes will use whatever mint URL the server defaults to");
+                println!("  • You can still mint using your local wallet's mint configuration");
+                println!("\nNote: The official npubx.cash server supports this feature.");
+                println!("      Custom servers may not have it implemented.");
+            } else {
+                return Err(e.into());
+            }
+        }
+    }
+
+    Ok(())
+}
+
+async fn show_keys(multi_mint_wallet: &MultiMintWallet, mint_url: &str) -> Result<()> {
+    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+
+    let keys = wallet.get_npubcash_keys()?;
+    let npub = keys.public_key().to_bech32()?;
+    let nsec = keys.secret_key().to_bech32()?;
+
+    println!(
+        r#"
+╔═══════════════════════════════════════════════════════════════════════════╗
+║                         NpubCash Nostr Keys                               ║
+╠═══════════════════════════════════════════════════════════════════════════╣
+║                                                                           ║
+║  These keys are automatically derived from your wallet seed and are      ║
+║  used for authenticating with the NpubCash service.                      ║
+║                                                                           ║
+╠═══════════════════════════════════════════════════════════════════════════╣
+║                                                                           ║
+║  Public Key (npub):                                                       ║
+║  {}  ║
+║                                                                           ║
+║  NpubCash Address:                                                        ║
+║  {}@npubx.cash         ║
+║                                                                           ║
+╠═══════════════════════════════════════════════════════════════════════════╣
+║                                                                           ║
+║  Secret Key (nsec):                                                       ║
+║  {}  ║
+║                                                                           ║
+║  ⚠️  KEEP THIS SECRET! Anyone with this key can access your npubcash     ║
+║      account and authenticate as you.                                    ║
+║                                                                           ║
+╚═══════════════════════════════════════════════════════════════════════════╝
+"#,
+        npub, npub, nsec
+    );
+
+    Ok(())
+}

+ 8 - 2
crates/cdk-ffi/src/multi_mint_wallet.rs

@@ -348,7 +348,12 @@ impl MultiMintWallet {
 
         let proofs = self
             .inner
-            .mint(&cdk_mint_url, &quote_id, conditions)
+            .mint(
+                &cdk_mint_url,
+                &quote_id,
+                cdk::amount::SplitTarget::default(),
+                conditions,
+            )
             .await?;
         Ok(proofs.into_iter().map(|p| p.into()).collect())
     }
@@ -365,6 +370,7 @@ impl MultiMintWallet {
     ) -> Result<Proofs, FfiError> {
         let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
         let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?;
+        let timeout = std::time::Duration::from_secs(timeout_secs);
 
         let proofs = self
             .inner
@@ -373,7 +379,7 @@ impl MultiMintWallet {
                 &quote_id,
                 split_target.into(),
                 conditions,
-                timeout_secs,
+                timeout,
             )
             .await?;
         Ok(proofs.into_iter().map(|p| p.into()).collect())

+ 7 - 1
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -705,7 +705,13 @@ async fn test_melt_quote_status_after_melt_multi_mint_wallet() {
         .unwrap();
 
     let _proofs = multi_mint_wallet
-        .wait_for_mint_quote(&mint_url, &mint_quote.id, SplitTarget::default(), None, 60)
+        .wait_for_mint_quote(
+            &mint_url,
+            &mint_quote.id,
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(60),
+        )
         .await
         .expect("mint failed");
 

+ 8 - 2
crates/cdk-integration-tests/tests/multi_mint_wallet.rs

@@ -55,7 +55,13 @@ async fn fund_multi_mint_wallet(
         .unwrap();
 
     let proofs = wallet
-        .wait_for_mint_quote(mint_url, &mint_quote.id, SplitTarget::default(), None, 60)
+        .wait_for_mint_quote(
+            mint_url,
+            &mint_quote.id,
+            SplitTarget::default(),
+            None,
+            std::time::Duration::from_secs(60),
+        )
         .await
         .expect("mint failed");
 
@@ -117,7 +123,7 @@ async fn test_multi_mint_wallet_mint() {
 
     // Call mint() directly (quote should be Paid at this point)
     let proofs = multi_mint_wallet
-        .mint(&mint_url, &mint_quote.id, None)
+        .mint(&mint_url, &mint_quote.id, SplitTarget::default(), None)
         .await
         .unwrap();
 

+ 36 - 0
crates/cdk-npubcash/Cargo.toml

@@ -0,0 +1,36 @@
+[package]
+name = "cdk-npubcash"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+
+[dependencies]
+# Use workspace dependencies
+async-trait = { workspace = true }
+cashu = { workspace = true }
+cdk-common = { workspace = true, features = ["wallet"] }
+nostr-sdk = { workspace = true }
+reqwest = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true, features = ["full"] }
+tracing = { workspace = true }
+url = { workspace = true }
+
+# Additional dependencies not in workspace
+base64 = "0.22"
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["test-util"] }
+tracing-subscriber = { workspace = true }
+chrono = "0.4"
+rustls = { workspace = true }
+cdk = { path = "../cdk" }
+cdk-sqlite = { path = "../cdk-sqlite" }
+
+[lints]
+workspace = true

+ 74 - 0
crates/cdk-npubcash/README.md

@@ -0,0 +1,74 @@
+# cdk-npubcash
+
+Rust client SDK for the NpubCash v2 API.
+
+## Features
+
+- **HTTP Client**: Fetch quotes with automatic pagination
+- **NIP-98 Authentication**: Sign requests using Nostr keys
+- **JWT Token Caching**: Automatic token refresh and caching
+- **Quote Polling**: Subscribe to quote updates via polling
+- **Settings Management**: Configure mint URL
+
+## Usage
+
+```rust
+use cdk_npubcash::{NpubCashClient, JwtAuthProvider};
+use nostr_sdk::Keys;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let base_url = "https://npubx.cash".to_string();
+    let keys = Keys::generate();
+    
+    let auth_provider = JwtAuthProvider::new(base_url.clone(), keys);
+    let client = NpubCashClient::new(base_url, std::sync::Arc::new(auth_provider));
+
+    // Fetch all quotes
+    let quotes = client.get_quotes(None).await?;
+    println!("Found {} quotes", quotes.len());
+
+    // Fetch quotes since timestamp
+    let recent_quotes = client.get_quotes(Some(1234567890)).await?;
+
+    // Update mint URL setting
+    client.set_mint_url("https://example-mint.tld").await?;
+    
+    Ok(())
+}
+```
+
+## Examples
+
+The SDK includes several examples to help you get started:
+
+- **`basic_usage.rs`** - Demonstrates fetching quotes and basic client usage
+- **`manage_settings.rs`** - Illustrates settings management (mint URL)
+- **`create_and_wait_payment.rs`** - Demonstrates creating a npub.cash address and monitoring for payments
+
+**Note:** Quotes are always locked by default on the NPubCash server for security. The ability to toggle quote locking has been removed from the SDK.
+
+Run an example with:
+```bash
+cargo run --example basic_usage
+```
+
+Set environment variables to customize behavior:
+```bash
+export NPUBCASH_URL=https://npubx.cash
+export NOSTR_NSEC=nsec1...  # Optional: use specific Nostr keys
+cargo run --example create_and_wait_payment
+```
+
+## Authentication
+
+The SDK uses NIP-98 (Nostr HTTP Auth) to authenticate with the NpubCash service:
+
+1. Creates a NIP-98 signed event with the request URL and method
+2. Exchanges the NIP-98 token for a JWT token
+3. Caches the JWT token for subsequent requests
+4. Automatically refreshes the token when it expires
+
+## License
+
+MIT

+ 66 - 0
crates/cdk-npubcash/examples/basic_usage.rs

@@ -0,0 +1,66 @@
+//! Basic usage example for the `NpubCash` SDK
+//!
+//! This example demonstrates:
+//! - Creating a client with authentication
+//! - Fetching all quotes
+//! - Fetching quotes since a timestamp
+//! - Error handling
+
+use std::sync::Arc;
+
+use cdk_npubcash::{JwtAuthProvider, NpubCashClient};
+use nostr_sdk::Keys;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    tracing_subscriber::fmt::init();
+
+    let base_url =
+        std::env::var("NPUBCASH_URL").unwrap_or_else(|_| "https://npubx.cash".to_string());
+
+    let keys = if let Ok(nsec) = std::env::var("NOSTR_NSEC") {
+        Keys::parse(&nsec)?
+    } else {
+        println!("No NOSTR_NSEC found, generating new keys");
+        Keys::generate()
+    };
+
+    println!("Public key: {}", keys.public_key());
+
+    let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
+
+    let client = NpubCashClient::new(base_url, auth_provider);
+
+    println!("\n=== Fetching all quotes ===");
+    match client.get_quotes(None).await {
+        Ok(quotes) => {
+            println!("Successfully fetched {} quotes", quotes.len());
+            if let Some(first) = quotes.first() {
+                println!("\nFirst quote:");
+                println!("  ID: {}", first.id);
+                println!("  Amount: {}", first.amount);
+                println!("  Unit: {}", first.unit);
+            }
+        }
+        Err(e) => {
+            eprintln!("Error fetching quotes: {e}");
+        }
+    }
+
+    let one_hour_ago = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)?
+        .as_secs()
+        - 3600;
+
+    println!("\n=== Fetching quotes from last hour ===");
+    match client.get_quotes(Some(one_hour_ago)).await {
+        Ok(quotes) => {
+            println!("Found {} quotes in the last hour", quotes.len());
+        }
+        Err(e) => {
+            eprintln!("Error fetching recent quotes: {e}");
+        }
+    }
+
+    Ok(())
+}

+ 158 - 0
crates/cdk-npubcash/examples/create_and_wait_payment.rs

@@ -0,0 +1,158 @@
+//! Create a lightning address and wait for payment
+//!
+//! This example demonstrates:
+//! - Generating Nostr keys for authentication
+//! - Displaying the npub.cash URL for the user's public key
+//! - Polling for quote updates
+//! - Waiting for payment notifications
+//!
+//! Note: The current npub.cash SDK only supports reading quotes.
+//! To create a new quote/invoice, you would need to:
+//! 1. Use the npub.cash web interface or
+//! 2. Implement the POST /api/v2/wallet/quotes endpoint
+//!
+//! This example shows how to monitor for new quotes once they exist.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use cdk_npubcash::{JwtAuthProvider, NpubCashClient};
+use nostr_sdk::{Keys, ToBech32};
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    #[cfg(not(target_arch = "wasm32"))]
+    if rustls::crypto::CryptoProvider::get_default().is_none() {
+        let _ = rustls::crypto::ring::default_provider().install_default();
+    }
+
+    tracing_subscriber::fmt()
+        .with_max_level(tracing::Level::DEBUG)
+        .init();
+
+    tracing::debug!("Starting NpubCash Payment Monitor");
+    println!("=== NpubCash Payment Monitor ===\n");
+
+    let base_url =
+        std::env::var("NPUBCASH_URL").unwrap_or_else(|_| "https://npubx.cash".to_string());
+    tracing::debug!("Using base URL: {}", base_url);
+
+    let keys = if let Ok(nsec) = std::env::var("NOSTR_NSEC") {
+        tracing::debug!("Loading Nostr keys from NOSTR_NSEC environment variable");
+        println!("Using provided Nostr keys from NOSTR_NSEC");
+        Keys::parse(&nsec)?
+    } else {
+        tracing::debug!("No NOSTR_NSEC found, generating new keys");
+        println!("No NOSTR_NSEC found, generating new keys");
+        let new_keys = Keys::generate();
+        println!("\n⚠️  Save this private key (nsec) to reuse the same identity:");
+        println!("   {}", new_keys.secret_key().to_bech32()?);
+        println!();
+        new_keys
+    };
+
+    let npub = keys.public_key().to_bech32()?;
+    tracing::debug!("Generated npub: {}", npub);
+    println!("Public key (npub): {npub}");
+    println!("\nYour npub.cash address:");
+    println!("   {npub}/@{base_url}");
+    println!("\nAnyone can send you ecash at this address!");
+    println!("{}", "=".repeat(60));
+
+    tracing::debug!("Creating JWT auth provider");
+    let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
+
+    tracing::debug!("Initializing NpubCash client");
+    let client = NpubCashClient::new(base_url, auth_provider);
+
+    println!("\n=== Checking for existing quotes ===");
+    tracing::debug!("Fetching all existing quotes");
+    match client.get_quotes(None).await {
+        Ok(quotes) => {
+            tracing::debug!("Successfully fetched {} quotes", quotes.len());
+            if quotes.is_empty() {
+                println!("No quotes found yet.");
+                println!("\nTo create a new quote:");
+                println!("  1. Visit your npub.cash address in a browser");
+                println!("  2. Or use the npub.cash web interface");
+                println!("  3. Or implement the POST /api/v2/wallet/quotes endpoint");
+            } else {
+                println!("Found {} existing quote(s):", quotes.len());
+                for (i, quote) in quotes.iter().enumerate() {
+                    tracing::debug!(
+                        "Quote {}: ID={}, amount={}, unit={}",
+                        i + 1,
+                        quote.id,
+                        quote.amount,
+                        quote.unit
+                    );
+                    println!("\n{}. Quote ID: {}", i + 1, quote.id);
+                    println!("   Amount: {} {}", quote.amount, quote.unit);
+                }
+            }
+        }
+        Err(e) => {
+            tracing::error!("Error fetching quotes: {}", e);
+            eprintln!("Error fetching quotes: {e}");
+        }
+    }
+
+    println!("\n{}", "=".repeat(60));
+    println!("=== Polling for quote updates ===");
+    println!("Checking for new payments every 5 seconds... Press Ctrl+C to stop.\n");
+
+    tracing::debug!("Starting quote polling with 5 second interval");
+
+    // Get initial timestamp for polling
+    let mut last_timestamp = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)?
+        .as_secs();
+
+    // Poll for quotes and handle Ctrl+C
+    tokio::select! {
+        _ = async {
+            loop {
+                tokio::time::sleep(Duration::from_secs(5)).await;
+
+                match client.get_quotes(Some(last_timestamp)).await {
+                    Ok(quotes) => {
+                        if !quotes.is_empty() {
+                            tracing::debug!("Found {} new quotes", quotes.len());
+
+                            // Update timestamp to most recent quote
+                            if let Some(max_ts) = quotes.iter().map(|q| q.created_at).max() {
+                                last_timestamp = max_ts;
+                            }
+
+                            for quote in quotes {
+                                tracing::info!(
+                                    "New quote received: ID={}, amount={}, unit={}",
+                                    quote.id,
+                                    quote.amount,
+                                    quote.unit
+                                );
+                                println!("🔔 New quote received!");
+                                println!("   Quote ID: {}", quote.id);
+                                println!("   Amount: {} {}", quote.amount, quote.unit);
+                                println!(
+                                    "   Timestamp: {}",
+                                    chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
+                                );
+                                println!();
+                            }
+                        }
+                    }
+                    Err(e) => {
+                        tracing::debug!("Error polling quotes: {}", e);
+                    }
+                }
+            }
+        } => {}
+        _ = tokio::signal::ctrl_c() => {
+            tracing::info!("Received Ctrl+C signal, stopping payment monitor");
+        }
+    }
+    println!("\n✓ Stopped monitoring for payments");
+
+    Ok(())
+}

+ 53 - 0
crates/cdk-npubcash/examples/manage_settings.rs

@@ -0,0 +1,53 @@
+//! Settings management example for the `NpubCash` SDK
+//!
+//! This example demonstrates:
+//! - Setting the mint URL
+//! - Handling API responses
+//!
+//! Note: Quote locking is always enabled by default on the NPubCash server.
+//! The ability to toggle quote locking has been removed from the SDK.
+
+use std::sync::Arc;
+
+use cdk_npubcash::{JwtAuthProvider, NpubCashClient};
+use nostr_sdk::Keys;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    tracing_subscriber::fmt::init();
+
+    let base_url =
+        std::env::var("NPUBCASH_URL").unwrap_or_else(|_| "https://npubx.cash".to_string());
+
+    let keys = if let Ok(nsec) = std::env::var("NOSTR_NSEC") {
+        Keys::parse(&nsec)?
+    } else {
+        println!("No NOSTR_NSEC found, generating new keys");
+        Keys::generate()
+    };
+
+    println!("Public key: {}", keys.public_key());
+
+    let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
+
+    let client = NpubCashClient::new(base_url, auth_provider);
+
+    println!("\n=== Setting Mint URL ===");
+    let mint_url = "https://testnut.cashu.space";
+    match client.set_mint_url(mint_url).await {
+        Ok(response) => {
+            println!("✓ Successfully set mint URL");
+            println!(
+                "  Current mint URL: {}",
+                response.data.user.mint_url.as_deref().unwrap_or("None")
+            );
+            println!("  Lock quotes: {}", response.data.user.lock_quote);
+            println!("\nNote: Quotes are always locked by default for security.");
+        }
+        Err(e) => {
+            eprintln!("✗ Error setting mint URL: {e}");
+        }
+    }
+
+    Ok(())
+}

+ 212 - 0
crates/cdk-npubcash/src/auth.rs

@@ -0,0 +1,212 @@
+//! Authentication providers for NpubCash API
+//!
+//! Implements NIP-98 and JWT authentication
+
+use std::sync::Arc;
+use std::time::{Duration, SystemTime};
+
+use base64::Engine;
+use nostr_sdk::{EventBuilder, Keys, Kind, Tag};
+use tokio::sync::RwLock;
+
+use crate::types::Nip98Response;
+use crate::{Error, Result};
+
+#[derive(Debug)]
+struct CachedToken {
+    token: String,
+    expires_at: SystemTime,
+}
+
+/// JWT authentication provider using NIP-98
+#[derive(Debug)]
+pub struct JwtAuthProvider {
+    base_url: String,
+    keys: Keys,
+    http_client: reqwest::Client,
+    cached_token: Arc<RwLock<Option<CachedToken>>>,
+}
+
+impl JwtAuthProvider {
+    /// Create a new JWT authentication provider
+    ///
+    /// # Arguments
+    ///
+    /// * `base_url` - Base URL of the NpubCash service
+    /// * `keys` - Nostr keys for signing NIP-98 tokens
+    pub fn new(base_url: String, keys: Keys) -> Self {
+        Self {
+            base_url,
+            keys,
+            http_client: reqwest::Client::new(),
+            cached_token: Arc::new(RwLock::new(None)),
+        }
+    }
+
+    /// Ensure we have a valid cached JWT token, fetching a new one if needed
+    ///
+    /// This method checks the cache first and returns the cached token if it's still valid.
+    /// If the cache is empty or expired, it fetches a new JWT token from the API.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if token generation or API request fails
+    async fn ensure_cached_token(&self) -> Result<String> {
+        // Check if we have a valid cached token
+        if let Some(token) = self.get_valid_cached_token().await {
+            return Ok(token);
+        }
+
+        // Fetch a new JWT token from the API
+        let token = self.fetch_fresh_jwt_token().await?;
+
+        // Cache the new token
+        self.cache_token(&token).await;
+
+        Ok(token)
+    }
+
+    /// Get a valid token from cache, if one exists and hasn't expired
+    async fn get_valid_cached_token(&self) -> Option<String> {
+        let cache = self.cached_token.read().await;
+        cache.as_ref().and_then(|cached| {
+            if cached.expires_at > SystemTime::now() {
+                Some(cached.token.clone())
+            } else {
+                None
+            }
+        })
+    }
+
+    /// Fetch a fresh JWT token from the NpubCash API using NIP-98 authentication
+    async fn fetch_fresh_jwt_token(&self) -> Result<String> {
+        let auth_url = format!("{}/api/v2/auth/nip98", self.base_url);
+
+        // Create NIP-98 token for authentication
+        let nostr_token = self.create_nip98_token_with_logging(&auth_url)?;
+
+        // Send authentication request
+        let response = self.send_auth_request(&auth_url, &nostr_token).await?;
+
+        // Parse and validate response
+        self.parse_jwt_response(response).await
+    }
+
+    /// Create a NIP-98 token with debug logging
+    fn create_nip98_token_with_logging(&self, auth_url: &str) -> Result<String> {
+        tracing::debug!("Creating NIP-98 token for URL: {}", auth_url);
+        let nostr_token = self.create_nip98_token(auth_url, "GET")?;
+        tracing::debug!(
+            "NIP-98 token created (first 50 chars): {}",
+            &nostr_token[..50.min(nostr_token.len())]
+        );
+        Ok(nostr_token)
+    }
+
+    /// Send the authentication request to the API
+    async fn send_auth_request(
+        &self,
+        auth_url: &str,
+        nostr_token: &str,
+    ) -> Result<reqwest::Response> {
+        tracing::debug!("Sending request to: {}", auth_url);
+        tracing::debug!(
+            "Authorization header: Nostr {}",
+            &nostr_token[..50.min(nostr_token.len())]
+        );
+
+        let response = self
+            .http_client
+            .get(auth_url)
+            .header("Authorization", format!("Nostr {nostr_token}"))
+            .header("Content-Type", "application/json")
+            .header("Accept", "application/json")
+            .header("User-Agent", "cdk-npubcash/0.13.0")
+            .send()
+            .await?;
+
+        tracing::debug!("Response status: {}", response.status());
+        Ok(response)
+    }
+
+    /// Parse the JWT response from the API
+    async fn parse_jwt_response(&self, response: reqwest::Response) -> Result<String> {
+        let status = response.status();
+
+        if !status.is_success() {
+            let error_text = response.text().await.unwrap_or_default();
+            tracing::error!("Auth failed - Status: {}, Body: {}", status, error_text);
+            return Err(Error::Auth(format!(
+                "Failed to get JWT: {status} - {error_text}"
+            )));
+        }
+
+        let nip98_response: Nip98Response = response.json().await?;
+        Ok(nip98_response.data.token)
+    }
+
+    /// Cache the JWT token with a 5-minute expiration
+    async fn cache_token(&self, token: &str) {
+        let expires_at = SystemTime::now() + Duration::from_secs(5 * 60);
+        let mut cache = self.cached_token.write().await;
+        *cache = Some(CachedToken {
+            token: token.to_string(),
+            expires_at,
+        });
+    }
+
+    fn create_nip98_token(&self, url: &str, method: &str) -> Result<String> {
+        let u_tag = Tag::custom(
+            nostr_sdk::TagKind::Custom(std::borrow::Cow::Borrowed("u")),
+            vec![url],
+        );
+        let method_tag = Tag::custom(
+            nostr_sdk::TagKind::Custom(std::borrow::Cow::Borrowed("method")),
+            vec![method],
+        );
+
+        let event = EventBuilder::new(Kind::Custom(27235), "")
+            .tags(vec![u_tag, method_tag])
+            .sign_with_keys(&self.keys)
+            .map_err(|e| Error::Nostr(e.to_string()))?;
+
+        let json = serde_json::to_string(&event)?;
+        tracing::debug!("NIP-98 event JSON: {}", json);
+        let encoded = base64::engine::general_purpose::STANDARD.encode(json);
+        tracing::debug!("Base64 encoded token length: {}", encoded.len());
+        Ok(encoded)
+    }
+
+    /// Get a Bearer token for authenticated requests
+    ///
+    /// # Arguments
+    ///
+    /// * `_url` - The URL being accessed (unused, kept for future extensibility)
+    /// * `_method` - The HTTP method being used (unused, kept for future extensibility)
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if token generation or fetching fails
+    pub async fn get_auth_token(&self, _url: &str, _method: &str) -> Result<String> {
+        let token = self.ensure_cached_token().await?;
+        Ok(format!("Bearer {token}"))
+    }
+
+    /// Get a NIP-98 auth header for direct authentication
+    ///
+    /// This creates a fresh NIP-98 signed event for the specific URL and method,
+    /// returning the full Authorization header value (e.g., "Nostr <base64_event>").
+    ///
+    /// # Arguments
+    ///
+    /// * `url` - The URL being accessed
+    /// * `method` - The HTTP method being used (GET, POST, PATCH, etc.)
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if token generation fails
+    pub fn get_nip98_auth_header(&self, url: &str, method: &str) -> Result<String> {
+        let token = self.create_nip98_token(url, method)?;
+        Ok(format!("Nostr {token}"))
+    }
+}

+ 305 - 0
crates/cdk-npubcash/src/client.rs

@@ -0,0 +1,305 @@
+//! HTTP client for NpubCash API
+
+use std::sync::Arc;
+
+use reqwest::Client as HttpClient;
+use tracing::instrument;
+
+use crate::auth::JwtAuthProvider;
+use crate::error::{Error, Result};
+use crate::types::{Quote, QuotesResponse};
+
+const API_PATHS_QUOTES: &str = "/api/v2/wallet/quotes";
+const PAGINATION_LIMIT: usize = 50;
+const THROTTLE_DELAY_MS: u64 = 200;
+
+/// Main client for interacting with the NpubCash API
+pub struct NpubCashClient {
+    base_url: String,
+    auth_provider: Arc<JwtAuthProvider>,
+    http_client: HttpClient,
+}
+
+impl std::fmt::Debug for NpubCashClient {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("NpubCashClient")
+            .field("base_url", &self.base_url)
+            .field("auth_provider", &self.auth_provider)
+            .finish_non_exhaustive()
+    }
+}
+
+impl NpubCashClient {
+    /// Create a new NpubCash client
+    ///
+    /// # Arguments
+    ///
+    /// * `base_url` - Base URL of the NpubCash service (e.g., <https://npubx.cash>)
+    /// * `auth_provider` - Authentication provider for signing requests
+    pub fn new(base_url: String, auth_provider: Arc<JwtAuthProvider>) -> Self {
+        Self {
+            base_url,
+            auth_provider,
+            http_client: HttpClient::new(),
+        }
+    }
+
+    /// Fetch quotes, optionally filtered by timestamp
+    ///
+    /// # Arguments
+    ///
+    /// * `since` - Optional Unix timestamp to fetch quotes from. If `None`, fetches all quotes.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the API request fails or authentication fails
+    ///
+    /// # Examples
+    ///
+    /// ```no_run
+    /// # use cdk_npubcash::{NpubCashClient, JwtAuthProvider};
+    /// # use nostr_sdk::Keys;
+    /// # use std::sync::Arc;
+    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
+    /// # let base_url = "https://npubx.cash".to_string();
+    /// # let keys = Keys::generate();
+    /// # let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
+    /// # let client = NpubCashClient::new(base_url, auth_provider);
+    /// // Fetch all quotes
+    /// let all_quotes = client.get_quotes(None).await?;
+    ///
+    /// // Fetch quotes since a specific timestamp
+    /// let recent_quotes = client.get_quotes(Some(1234567890)).await?;
+    /// # Ok(())
+    /// # }
+    /// ```
+    #[instrument(skip(self))]
+    pub async fn get_quotes(&self, since: Option<u64>) -> Result<Vec<Quote>> {
+        if let Some(ts) = since {
+            tracing::debug!("Fetching quotes since timestamp: {}", ts);
+        } else {
+            tracing::debug!("Fetching all quotes");
+        }
+        self.fetch_paginated_quotes(since).await
+    }
+
+    /// Fetch quotes with pagination support
+    ///
+    /// This method handles automatic pagination, fetching all available quotes
+    /// matching the criteria. It throttles requests to avoid overwhelming the API.
+    ///
+    /// # Arguments
+    ///
+    /// * `since` - Optional timestamp to filter quotes created after this time
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if any page fetch fails
+    async fn fetch_paginated_quotes(&self, since: Option<u64>) -> Result<Vec<Quote>> {
+        let mut all_quotes = Vec::new();
+        let mut offset = 0;
+
+        loop {
+            // Build the URL for this page
+            let url = self.build_quotes_url(offset, since)?;
+
+            // Fetch the current page
+            let response: QuotesResponse = self.authenticated_request(url.as_str(), "GET").await?;
+
+            // Collect quotes from this page
+            let fetched_count = response.data.quotes.len();
+            all_quotes.extend(response.data.quotes);
+
+            tracing::debug!(
+                "Fetched {} quotes. Total fetched: {}",
+                fetched_count,
+                all_quotes.len()
+            );
+
+            // Check if we should continue paginating
+            offset += PAGINATION_LIMIT;
+            if !Self::should_fetch_next_page(offset, response.metadata.total) {
+                break;
+            }
+
+            // Throttle to avoid overwhelming the API
+            self.throttle_request().await;
+        }
+
+        tracing::info!(
+            "Successfully fetched a total of {} quotes",
+            all_quotes.len()
+        );
+        Ok(all_quotes)
+    }
+
+    /// Build the URL for fetching quotes with pagination and filters
+    fn build_quotes_url(&self, offset: usize, since: Option<u64>) -> Result<url::Url> {
+        let mut url = url::Url::parse(&format!("{}{}", self.base_url, API_PATHS_QUOTES))?;
+
+        // Add pagination parameters
+        url.query_pairs_mut()
+            .append_pair("offset", &offset.to_string())
+            .append_pair("limit", &PAGINATION_LIMIT.to_string());
+
+        // Add optional timestamp filter
+        if let Some(since_val) = since {
+            url.query_pairs_mut()
+                .append_pair("since", &since_val.to_string());
+        }
+
+        Ok(url)
+    }
+
+    /// Set the mint URL for the user
+    ///
+    /// Updates the default mint URL used by the NpubCash server when creating quotes.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - URL of the Cashu mint to use
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the API request fails or authentication fails.
+    /// Returns `UnsupportedEndpoint` if the server doesn't support this feature.
+    #[instrument(skip(self, mint_url))]
+    pub async fn set_mint_url(
+        &self,
+        mint_url: impl Into<String>,
+    ) -> Result<crate::types::UserResponse> {
+        use serde::Serialize;
+
+        const MINT_URL_PATH: &str = "/api/v2/user/mint";
+
+        #[derive(Serialize)]
+        struct MintUrlPayload {
+            mint_url: String,
+        }
+
+        let url = format!("{}{}", self.base_url, MINT_URL_PATH);
+        let payload = MintUrlPayload {
+            mint_url: mint_url.into(),
+        };
+
+        // Get NIP-98 authentication header (not JWT Bearer)
+        let auth_header = self.auth_provider.get_nip98_auth_header(&url, "PATCH")?;
+
+        // Send PATCH request
+        let response = self
+            .http_client
+            .patch(&url)
+            .header("Authorization", auth_header)
+            .header("Content-Type", "application/json")
+            .header("Accept", "application/json")
+            .header("User-Agent", "cdk-npubcash/0.13.0")
+            .json(&payload)
+            .send()
+            .await?;
+
+        let status = response.status();
+
+        // Handle error responses
+        if !status.is_success() {
+            let error_text = response.text().await.unwrap_or_default();
+            return Err(Error::Api {
+                message: error_text,
+                status: status.as_u16(),
+            });
+        }
+
+        // Get response text for debugging
+        let response_text = response.text().await?;
+        tracing::debug!("set_mint_url response: {}", response_text);
+
+        // Parse JSON response
+        serde_json::from_str(&response_text).map_err(|e| {
+            tracing::error!("Failed to parse response: {} - Body: {}", e, response_text);
+            Error::Custom(format!("JSON parse error: {e}"))
+        })
+    }
+
+    /// Determine if we should fetch the next page of results
+    const fn should_fetch_next_page(current_offset: usize, total_available: usize) -> bool {
+        current_offset < total_available
+    }
+
+    /// Throttle requests to avoid overwhelming the API
+    async fn throttle_request(&self) {
+        tracing::debug!("Throttling for {}ms...", THROTTLE_DELAY_MS);
+        tokio::time::sleep(tokio::time::Duration::from_millis(THROTTLE_DELAY_MS)).await;
+    }
+
+    /// Make an authenticated HTTP request to the API
+    ///
+    /// This method handles authentication, sends the request, and parses the response.
+    ///
+    /// # Arguments
+    ///
+    /// * `url` - Full URL to request
+    /// * `method` - HTTP method (e.g., "GET", "POST")
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if authentication fails, request fails, or response parsing fails
+    async fn authenticated_request<T>(&self, url: &str, method: &str) -> Result<T>
+    where
+        T: serde::de::DeserializeOwned,
+    {
+        // Extract URL for authentication (without query parameters)
+        let url_for_auth = crate::extract_auth_url(url)?;
+
+        // Get authentication token
+        let auth_token = self
+            .auth_provider
+            .get_auth_token(&url_for_auth, method)
+            .await?;
+
+        // Send the HTTP request with authentication headers
+        tracing::debug!("Making {} request to {}", method, url);
+        let response = self
+            .http_client
+            .get(url)
+            .header("Authorization", auth_token)
+            .header("Content-Type", "application/json")
+            .header("Accept", "application/json")
+            .header("User-Agent", "cdk-npubcash/0.13.0")
+            .send()
+            .await?;
+
+        tracing::debug!("Response status: {}", response.status());
+
+        // Parse and return the JSON response
+        self.parse_response(response).await
+    }
+
+    /// Parse the HTTP response and deserialize the JSON body
+    async fn parse_response<T>(&self, response: reqwest::Response) -> Result<T>
+    where
+        T: serde::de::DeserializeOwned,
+    {
+        let status = response.status();
+
+        // Get the response text
+        let response_text = response.text().await?;
+
+        // Handle error status codes
+        if !status.is_success() {
+            tracing::debug!("Error response ({}): {}", status, response_text);
+            return Err(Error::Api {
+                message: response_text,
+                status: status.as_u16(),
+            });
+        }
+
+        // Parse successful JSON response
+        tracing::debug!("Response body: {}", response_text);
+        let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
+            tracing::error!("JSON parse error: {} - Body: {}", e, response_text);
+            Error::Custom(format!("JSON parse error: {e}"))
+        })?;
+
+        tracing::debug!("Request successful");
+        Ok(data)
+    }
+}

+ 43 - 0
crates/cdk-npubcash/src/error.rs

@@ -0,0 +1,43 @@
+//! Error types for the NpubCash SDK
+
+use thiserror::Error;
+
+/// Result type for NpubCash SDK operations
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Error types that can occur when using the NpubCash SDK
+#[derive(Debug, Error)]
+pub enum Error {
+    /// API returned an error response
+    #[error("API error ({status}): {message}")]
+    Api {
+        /// Error message from the API
+        message: String,
+        /// HTTP status code
+        status: u16,
+    },
+
+    /// Authentication failed
+    #[error("Authentication failed: {0}")]
+    Auth(String),
+
+    /// HTTP request failed
+    #[error("HTTP request failed: {0}")]
+    Http(#[from] reqwest::Error),
+
+    /// JSON serialization/deserialization error
+    #[error("JSON serialization error: {0}")]
+    Serde(#[from] serde_json::Error),
+
+    /// Invalid URL
+    #[error("Invalid URL: {0}")]
+    Url(#[from] url::ParseError),
+
+    /// Nostr signing error
+    #[error("Nostr signing error: {0}")]
+    Nostr(String),
+
+    /// Custom error message
+    #[error("{0}")]
+    Custom(String),
+}

+ 128 - 0
crates/cdk-npubcash/src/lib.rs

@@ -0,0 +1,128 @@
+//! # NpubCash SDK
+//!
+//! Rust client SDK for the NpubCash v2 API.
+//!
+//! ## Features
+//!
+//! - HTTP client for fetching quotes with auto-pagination
+//! - NIP-98 and JWT authentication
+//! - User settings management
+//!
+//! ## Quick Start
+//!
+//! ```no_run
+//! use std::sync::Arc;
+//!
+//! use cdk_npubcash::{JwtAuthProvider, NpubCashClient};
+//! use nostr_sdk::Keys;
+//!
+//! #[tokio::main]
+//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
+//!     // Create authentication provider with Nostr keys
+//!     let base_url = "https://npubx.cash".to_string();
+//!     let keys = Keys::generate();
+//!     let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
+//!
+//!     // Create the NpubCash client
+//!     let client = NpubCashClient::new(base_url, auth_provider);
+//!
+//!     // Fetch all quotes
+//!     let quotes = client.get_quotes(None).await?;
+//!     println!("Found {} quotes", quotes.len());
+//!
+//!     // Fetch quotes since a specific timestamp
+//!     let recent_quotes = client.get_quotes(Some(1234567890)).await?;
+//!     println!("Found {} recent quotes", recent_quotes.len());
+//!
+//!     // Update mint URL setting
+//!     client.set_mint_url("https://example-mint.tld").await?;
+//!
+//!     Ok(())
+//! }
+//! ```
+//!
+//! ## Authentication
+//!
+//! The SDK uses NIP-98 HTTP authentication for initial requests and JWT tokens
+//! for subsequent requests. The [`JwtAuthProvider`] handles this automatically,
+//! including token caching and refresh.
+//!
+//! ## Fetching Quotes
+//!
+//! ```no_run
+//! # use cdk_npubcash::{NpubCashClient, JwtAuthProvider};
+//! # use nostr_sdk::Keys;
+//! # use std::sync::Arc;
+//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
+//! # let base_url = "https://npubx.cash".to_string();
+//! # let keys = Keys::generate();
+//! # let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
+//! # let client = NpubCashClient::new(base_url, auth_provider);
+//! // Fetch all quotes
+//! let all_quotes = client.get_quotes(None).await?;
+//!
+//! // Fetch quotes since a specific timestamp
+//! let recent_quotes = client.get_quotes(Some(1234567890)).await?;
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! ## Managing Settings
+//!
+//! ```no_run
+//! # use cdk_npubcash::{NpubCashClient, JwtAuthProvider};
+//! # use nostr_sdk::Keys;
+//! # use std::sync::Arc;
+//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
+//! # let base_url = "https://npubx.cash".to_string();
+//! # let keys = Keys::generate();
+//! # let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
+//! # let client = NpubCashClient::new(base_url, auth_provider);
+//! // Set mint URL
+//! let response = client.set_mint_url("https://my-mint.com").await?;
+//! println!("Mint URL: {:?}", response.data.user.mint_url);
+//! println!("Lock quote: {}", response.data.user.lock_quote);
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! **Note:** Quotes are always locked by default on the NPubCash server for security.
+
+#![warn(missing_docs)]
+#![allow(clippy::doc_markdown)]
+
+pub mod auth;
+pub mod client;
+pub mod error;
+pub mod types;
+
+// Re-export main types for convenient access
+pub use auth::JwtAuthProvider;
+pub use client::NpubCashClient;
+pub use error::{Error, Result};
+pub use types::{
+    Metadata, Nip98Data, Nip98Response, Quote, QuotesData, QuotesResponse, UserData, UserResponse,
+};
+
+/// Extract authentication URL (scheme + host + path, no query params)
+///
+/// # Arguments
+///
+/// * `url` - The full URL to parse
+///
+/// # Errors
+///
+/// Returns an error if the URL is invalid or missing required components
+pub(crate) fn extract_auth_url(url: &str) -> Result<String> {
+    let parsed_url = url::Url::parse(url)?;
+    let host = parsed_url
+        .host_str()
+        .ok_or_else(|| Error::Custom("Invalid URL: missing host".to_string()))?;
+
+    Ok(format!(
+        "{}://{}{}",
+        parsed_url.scheme(),
+        host,
+        parsed_url.path()
+    ))
+}

+ 169 - 0
crates/cdk-npubcash/src/types.rs

@@ -0,0 +1,169 @@
+//! Type definitions for NpubCash API
+
+use std::str::FromStr;
+
+use cashu::nut00::KnownMethod;
+use cashu::PaymentMethod;
+use cdk_common::mint_url::MintUrl;
+use cdk_common::nuts::{CurrencyUnit, MintQuoteState};
+use cdk_common::wallet::MintQuote;
+use cdk_common::Amount;
+use serde::{Deserialize, Serialize};
+
+/// Default mint URL used when quote doesn't specify one
+const DEFAULT_MINT_URL: &str = "http://localhost:3338";
+
+/// A quote from the NpubCash service
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Quote {
+    /// Unique identifier for the quote
+    #[serde(rename = "quoteId")]
+    pub id: String,
+    /// Amount in the specified unit
+    pub amount: u64,
+    /// Currency or unit for the amount (optional, defaults to "sat")
+    #[serde(default = "default_unit")]
+    pub unit: String,
+    /// Unix timestamp when the quote was created
+    #[serde(default)]
+    pub created_at: u64,
+    /// Unix timestamp when the quote was paid (optional)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub paid_at: Option<u64>,
+    /// Unix timestamp when the quote expires (optional)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub expires_at: Option<u64>,
+    /// Mint URL associated with the quote (optional)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mint_url: Option<String>,
+    /// Lightning invoice request (optional)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub request: Option<String>,
+    /// Quote state (e.g., "PAID", "PENDING") (optional)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub state: Option<String>,
+    /// Whether the quote is locked (optional)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub locked: Option<bool>,
+}
+
+fn default_unit() -> String {
+    "sat".to_string()
+}
+
+/// Response containing a list of quotes with pagination metadata
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct QuotesResponse {
+    /// Quote data
+    pub data: QuotesData,
+    /// Pagination metadata
+    pub metadata: Metadata,
+}
+
+/// Container for quote data
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct QuotesData {
+    /// List of quotes
+    pub quotes: Vec<Quote>,
+}
+
+/// Pagination metadata
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Metadata {
+    /// Total number of available items
+    pub total: usize,
+    /// Current offset (optional, may not be present in all responses)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub offset: Option<usize>,
+    /// Items per page
+    pub limit: usize,
+    /// Since timestamp (optional, present when querying with since parameter)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub since: Option<u64>,
+}
+
+/// Response containing user settings
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UserResponse {
+    /// Whether the request resulted in an error
+    #[serde(default)]
+    pub error: bool,
+    /// User data container
+    pub data: UserDataContainer,
+}
+
+/// Container for user data
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct UserDataContainer {
+    /// User settings
+    pub user: UserData,
+}
+
+/// User settings data
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UserData {
+    /// User's public key
+    pub pubkey: String,
+    /// Configured mint URL
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mint_url: Option<String>,
+    /// Whether quotes are locked
+    #[serde(default)]
+    pub lock_quote: bool,
+}
+
+/// NIP-98 authentication response
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Nip98Response {
+    /// NIP-98 response data
+    pub data: Nip98Data,
+}
+
+/// NIP-98 token data
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Nip98Data {
+    /// JWT token
+    pub token: String,
+}
+
+impl From<Quote> for MintQuote {
+    fn from(quote: Quote) -> Self {
+        let mint_url = quote
+            .mint_url
+            .and_then(|url| MintUrl::from_str(&url).ok())
+            .unwrap_or_else(|| {
+                MintUrl::from_str(DEFAULT_MINT_URL).expect("default mint URL should be valid")
+            });
+
+        let unit = CurrencyUnit::from_str(&quote.unit).unwrap_or(CurrencyUnit::Sat);
+
+        let state = match quote.state.as_deref() {
+            Some("PAID") => MintQuoteState::Paid,
+            Some("ISSUED") => MintQuoteState::Issued,
+            _ => MintQuoteState::Unpaid,
+        };
+
+        let expiry = quote.expires_at.unwrap_or(quote.created_at + 86400);
+
+        Self {
+            id: quote.id,
+            mint_url,
+            payment_method: PaymentMethod::Known(KnownMethod::Bolt11),
+            amount: Some(Amount::from(quote.amount)),
+            unit,
+            request: quote.request.unwrap_or_default(),
+            state,
+            expiry,
+            secret_key: None,
+            amount_issued: Amount::ZERO,
+            amount_paid: if quote.paid_at.is_some() {
+                Amount::from(quote.amount)
+            } else {
+                Amount::ZERO
+            },
+        }
+    }
+}

+ 10 - 0
crates/cdk/Cargo.toml

@@ -14,6 +14,7 @@ license.workspace = true
 default = ["mint", "wallet", "auth", "nostr", "bip353"]
 wallet = ["dep:futures", "dep:reqwest", "cdk-common/wallet", "dep:rustls"]
 nostr = ["wallet", "dep:nostr-sdk", "cdk-common/nostr"]
+npubcash = ["wallet", "nostr", "dep:cdk-npubcash"]
 mint = ["dep:futures", "dep:reqwest", "cdk-common/mint", "cdk-signatory"]
 auth = ["dep:jsonwebtoken", "cdk-common/auth", "cdk-common/auth"]
 bip353 = ["dep:hickory-resolver"]
@@ -58,6 +59,7 @@ utoipa = { workspace = true, optional = true }
 uuid.workspace = true
 jsonwebtoken = { workspace = true, optional = true }
 nostr-sdk = { workspace = true, optional = true }
+cdk-npubcash = { workspace = true, optional = true }
 cdk-prometheus = {workspace = true, optional = true}
 web-time.workspace = true
 zeroize = "1"
@@ -154,6 +156,14 @@ required-features = ["wallet", "nostr"]
 name = "token-proofs"
 required-features = ["wallet"]
 
+[[example]]
+name = "npubcash"
+required-features = ["npubcash"]
+
+[[example]]
+name = "multimint-npubcash"
+required-features = ["npubcash"]
+
 [dev-dependencies]
 rand.workspace = true
 cdk-sqlite.workspace = true

+ 166 - 0
crates/cdk/examples/multimint-npubcash.rs

@@ -0,0 +1,166 @@
+//! Example: MultiMint Wallet with NpubCash - Switching Active Mints
+//!
+//! This example demonstrates:
+//! 1. Creating a MultiMintWallet with multiple mints
+//! 2. Using NpubCash integration with the MultiMintWallet API
+//! 3. Switching the active mint for NpubCash deposits
+//! 4. Receiving payments to different mints and verifying balances
+//!
+//! Key concept: Since all wallets in a MultiMintWallet share the same seed, they all
+//! derive the same Nostr keypair. This means your npub.cash address stays the same,
+//! but you can change which mint receives the deposits.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use cdk::amount::SplitTarget;
+use cdk::mint_url::MintUrl;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::StreamExt;
+use cdk_sqlite::wallet::memory;
+use nostr_sdk::ToBech32;
+
+const NPUBCASH_URL: &str = "https://npubx.cash";
+const MINT_URL_1: &str = "https://fake.thesimplekid.dev";
+const MINT_URL_2: &str = "https://testnut.cashu.space";
+const PAYMENT_AMOUNT_MSATS: u64 = 10000; // 10 sats
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    println!("=== MultiMint Wallet with NpubCash Example ===\n");
+
+    // -------------------------------------------------------------------------
+    // Step 1: Create MultiMintWallet and add mints
+    // -------------------------------------------------------------------------
+    println!("Step 1: Setting up MultiMintWallet...\n");
+
+    let seed: [u8; 64] = {
+        let mut s = [0u8; 64];
+        use std::time::{SystemTime, UNIX_EPOCH};
+        let timestamp = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_nanos();
+        for (i, byte) in s.iter_mut().enumerate() {
+            *byte = ((timestamp >> (i % 16)) & 0xFF) as u8;
+        }
+        s
+    };
+
+    let localstore = memory::empty().await?;
+    let wallet = MultiMintWallet::new(Arc::new(localstore), seed, CurrencyUnit::Sat).await?;
+
+    let mint_url_1: MintUrl = MINT_URL_1.parse()?;
+    let mint_url_2: MintUrl = MINT_URL_2.parse()?;
+
+    wallet.add_mint(mint_url_1.clone()).await?;
+    wallet.add_mint(mint_url_2.clone()).await?;
+    println!("   Added mints: {}, {}\n", mint_url_1, mint_url_2);
+
+    // -------------------------------------------------------------------------
+    // Step 2: Enable NpubCash on mint 1
+    // -------------------------------------------------------------------------
+    println!("Step 2: Enabling NpubCash on mint 1...\n");
+
+    wallet
+        .enable_npubcash(mint_url_1.clone(), NPUBCASH_URL.to_string())
+        .await?;
+
+    let keys = wallet.get_npubcash_keys().await?;
+    let npub = keys.public_key().to_bech32()?;
+    let display_url = NPUBCASH_URL.trim_start_matches("https://");
+
+    println!("   Your npub.cash address: {}@{}", npub, display_url);
+    println!("   Active mint: {}\n", mint_url_1);
+
+    // -------------------------------------------------------------------------
+    // Step 3: Request and receive payment on mint 1
+    // -------------------------------------------------------------------------
+    println!("Step 3: Receiving payment on mint 1...\n");
+
+    request_invoice(&npub, PAYMENT_AMOUNT_MSATS).await?;
+    println!("   Waiting for payment...");
+    let mut stream = wallet.npubcash_proof_stream(
+        SplitTarget::default(),
+        None, // no spending conditions
+        Duration::from_secs(1),
+    );
+
+    let (_, proofs_1) = stream.next().await.ok_or("Stream ended unexpectedly")??;
+
+    let amount_1: u64 = proofs_1.total_amount()?.into();
+    println!("   Received {} sats on mint 1!\n", amount_1);
+
+    // -------------------------------------------------------------------------
+    // Step 4: Switch to mint 2 and receive payment
+    // -------------------------------------------------------------------------
+    println!("Step 4: Switching to mint 2 and receiving payment...\n");
+
+    wallet
+        .enable_npubcash(mint_url_2.clone(), NPUBCASH_URL.to_string())
+        .await?;
+    println!("   Switched to mint: {}", mint_url_2);
+
+    request_invoice(&npub, PAYMENT_AMOUNT_MSATS).await?;
+    println!("   Waiting for payment...");
+
+    // The stream is for the multimint wallet so it handles switching mints automatically
+    let (_, proofs_2) = stream.next().await.ok_or("Stream ended unexpectedly")??;
+
+    let amount_2: u64 = proofs_2.total_amount()?.into();
+    println!("   Received {} sats on mint 2!\n", amount_2);
+
+    // -------------------------------------------------------------------------
+    // Step 5: Verify balances
+    // -------------------------------------------------------------------------
+    println!("Step 5: Verifying balances...\n");
+
+    let balances = wallet.get_balances().await?;
+    for (mint, balance) in &balances {
+        println!("   {}: {} sats", mint, balance);
+    }
+
+    let total = wallet.total_balance().await?;
+    let expected_total = amount_1 + amount_2;
+
+    println!(
+        "\n   Total: {} sats (expected: {} sats)",
+        total, expected_total
+    );
+    println!(
+        "   Status: {}\n",
+        if total == expected_total.into() {
+            "OK"
+        } else {
+            "MISMATCH"
+        }
+    );
+
+    Ok(())
+}
+
+/// Request an invoice via LNURL-pay
+async fn request_invoice(npub: &str, amount_msats: u64) -> Result<(), Box<dyn std::error::Error>> {
+    let http_client = reqwest::Client::new();
+
+    let lnurlp_url = format!("{}/.well-known/lnurlp/{}", NPUBCASH_URL, npub);
+    let lnurlp_response: serde_json::Value =
+        http_client.get(&lnurlp_url).send().await?.json().await?;
+
+    let callback = lnurlp_response["callback"]
+        .as_str()
+        .ok_or("No callback URL")?;
+
+    let invoice_url = format!("{}?amount={}", callback, amount_msats);
+    let invoice_response: serde_json::Value =
+        http_client.get(&invoice_url).send().await?.json().await?;
+
+    let pr = invoice_response["pr"]
+        .as_str()
+        .ok_or("No payment request")?;
+    println!("   Invoice: {}...", &pr[..50.min(pr.len())]);
+
+    Ok(())
+}

+ 159 - 0
crates/cdk/examples/npubcash.rs

@@ -0,0 +1,159 @@
+//! Example: Requesting and processing a single NpubCash payment
+//!
+//! This example demonstrates:
+//! 1. Setting up a wallet with NpubCash integration
+//! 2. Requesting an invoice for a fixed amount via LNURL-pay
+//! 3. Waiting for the payment to be processed and ecash to be minted
+//!
+//! Environment variables:
+//! - NOSTR_NSEC: Your Nostr private key (generates new if not provided)
+//!
+//! Uses constants:
+//! - NPUBCASH_URL: https://npubx.cash
+//! - MINT_URL: https://fake.thesimplekid.dev (fake mint that auto-pays)
+//!
+//! This example uses the NpubCash Proof Stream which continuously polls and mints.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use cdk::amount::SplitTarget;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::Wallet;
+use cdk::StreamExt;
+use cdk_sqlite::wallet::memory;
+use nostr_sdk::{Keys, ToBech32};
+
+const NPUBCASH_URL: &str = "https://npubx.cash";
+const MINT_URL: &str = "https://fake.thesimplekid.dev";
+const PAYMENT_AMOUNT_MSATS: u64 = 100000; // 100 sats
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    tracing_subscriber::fmt()
+        .with_max_level(tracing::Level::INFO)
+        .init();
+
+    println!("=== NpubCash Example ===\n");
+
+    // Setup Nostr keys
+    let keys = if let Ok(nsec) = std::env::var("NOSTR_NSEC") {
+        println!("Using provided Nostr keys");
+        Keys::parse(&nsec)?
+    } else {
+        println!("Generating new Nostr keys");
+        let new_keys = Keys::generate();
+        println!("Public key (npub): {}", new_keys.public_key().to_bech32()?);
+        println!(
+            "Private key (save this!): {}\n",
+            new_keys.secret_key().to_bech32()?
+        );
+        new_keys
+    };
+
+    // Setup wallet
+    let mint_url = MINT_URL.to_string();
+
+    println!("Mint URL: {}", mint_url);
+
+    let localstore = memory::empty().await?;
+    let seed = keys.secret_key().to_secret_bytes();
+    let mut full_seed = [0u8; 64];
+    full_seed[..32].copy_from_slice(&seed);
+
+    let wallet = Arc::new(Wallet::new(
+        &mint_url,
+        CurrencyUnit::Sat,
+        Arc::new(localstore),
+        full_seed,
+        None,
+    )?);
+
+    // Enable NpubCash integration
+    let npubcash_url = NPUBCASH_URL.to_string();
+
+    println!("NpubCash URL: {}", npubcash_url);
+    wallet.enable_npubcash(npubcash_url.clone()).await?;
+    println!("✓ NpubCash integration enabled\n");
+
+    // Display the npub.cash address
+    let display_url = npubcash_url
+        .trim_start_matches("https://")
+        .trim_start_matches("http://");
+    println!("Your npub.cash address:");
+    println!("   {}@{}\n", keys.public_key().to_bech32()?, display_url);
+    println!("Requesting invoice for 100 sats from the fake mint...");
+
+    request_invoice(&keys.public_key().to_bech32()?, PAYMENT_AMOUNT_MSATS).await?;
+
+    println!("Invoice requested - the fake mint should auto-pay shortly.\n");
+
+    // Check if auto-minting is enabled
+    // Note: With the new stream API, auto-mint is always enabled as it's the primary purpose of the stream.
+    println!("Auto-mint is ENABLED - paid quotes will be automatically minted\n");
+
+    println!("Waiting for the invoice to be paid and processed...\n");
+
+    // Subscribe to quote updates and wait for the single payment
+    let mut stream =
+        wallet.npubcash_proof_stream(SplitTarget::default(), None, Duration::from_secs(5));
+
+    if let Some(result) = stream.next().await {
+        match result {
+            Ok((quote, proofs)) => {
+                let amount_str = quote
+                    .amount
+                    .map_or("unknown".to_string(), |a| a.to_string());
+                println!("Received payment for quote {}", quote.id);
+                println!("  ├─ Amount: {} {}", amount_str, quote.unit);
+
+                match proofs.total_amount() {
+                    Ok(amount) => {
+                        println!("  └─ Successfully minted {} sats!", amount);
+                        if let Ok(balance) = wallet.total_balance().await {
+                            println!("     New wallet balance: {} sats", balance);
+                        }
+                    }
+                    Err(e) => println!("  └─ Failed to calculate amount: {}", e),
+                }
+                println!();
+            }
+            Err(e) => {
+                println!("Error processing payment: {}", e);
+            }
+        }
+    } else {
+        println!("No payment received within the timeout period.");
+    }
+
+    // Show final wallet balance
+    let balance = wallet.total_balance().await?;
+    println!("Final wallet balance: {} sats\n", balance);
+
+    Ok(())
+}
+
+/// Request an invoice via LNURL-pay
+async fn request_invoice(npub: &str, amount_msats: u64) -> Result<(), Box<dyn std::error::Error>> {
+    let http_client = reqwest::Client::new();
+
+    let lnurlp_url = format!("{}/.well-known/lnurlp/{}", NPUBCASH_URL, npub);
+    let lnurlp_response: serde_json::Value =
+        http_client.get(&lnurlp_url).send().await?.json().await?;
+
+    let callback = lnurlp_response["callback"]
+        .as_str()
+        .ok_or("No callback URL")?;
+
+    let invoice_url = format!("{}?amount={}", callback, amount_msats);
+    let invoice_response: serde_json::Value =
+        http_client.get(&invoice_url).send().await?.json().await?;
+
+    let pr = invoice_response["pr"]
+        .as_str()
+        .ok_or("No payment request")?;
+    println!("   Invoice: {}...", &pr[..50.min(pr.len())]);
+
+    Ok(())
+}

+ 3 - 1
crates/cdk/src/wallet/builder.rs

@@ -6,7 +6,7 @@ use cdk_common::database;
 use cdk_common::parking_lot::RwLock;
 #[cfg(feature = "auth")]
 use cdk_common::AuthToken;
-#[cfg(feature = "auth")]
+#[cfg(any(feature = "auth", feature = "npubcash"))]
 use tokio::sync::RwLock as TokioRwLock;
 
 use crate::cdk_database::WalletDatabase;
@@ -252,6 +252,8 @@ impl WalletBuilder {
             target_proof_count: self.target_proof_count.unwrap_or(3),
             #[cfg(feature = "auth")]
             auth_wallet: Arc::new(TokioRwLock::new(self.auth_wallet)),
+            #[cfg(feature = "npubcash")]
+            npubcash_client: Arc::new(TokioRwLock::new(None)),
             seed,
             client: client.clone(),
             subscription: SubscriptionManager::new(client, self.use_http_subscription),

+ 5 - 1
crates/cdk/src/wallet/mod.rs

@@ -13,7 +13,7 @@ use cdk_common::parking_lot::RwLock;
 use cdk_common::subscription::WalletParams;
 use getrandom::getrandom;
 use subscription::{ActiveSubscription, SubscriptionManager};
-#[cfg(feature = "auth")]
+#[cfg(any(feature = "auth", feature = "npubcash"))]
 use tokio::sync::RwLock as TokioRwLock;
 use tracing::instrument;
 use zeroize::Zeroize;
@@ -50,6 +50,8 @@ mod melt;
 mod mint_connector;
 mod mint_metadata_cache;
 pub mod multi_mint_wallet;
+#[cfg(feature = "npubcash")]
+mod npubcash;
 pub mod payment_request;
 mod proofs;
 mod receive;
@@ -105,6 +107,8 @@ pub struct Wallet {
     metadata_cache_ttl: Arc<RwLock<Option<Duration>>>,
     #[cfg(feature = "auth")]
     auth_wallet: Arc<TokioRwLock<Option<AuthWallet>>>,
+    #[cfg(feature = "npubcash")]
+    npubcash_client: Arc<TokioRwLock<Option<Arc<cdk_npubcash::NpubCashClient>>>>,
     seed: [u8; 64],
     client: Arc<dyn MintConnector + Send + Sync>,
     subscription: SubscriptionManager,

+ 183 - 13
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -1180,6 +1180,25 @@ impl MultiMintWallet {
         Ok(quote)
     }
 
+    /// Mint tokens at a specific mint
+    #[instrument(skip(self))]
+    pub async fn mint(
+        &self,
+        mint_url: &MintUrl,
+        quote_id: &str,
+        amount_split_target: SplitTarget,
+        spending_conditions: Option<SpendingConditions>,
+    ) -> Result<Proofs, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet
+            .mint(quote_id, amount_split_target, spending_conditions)
+            .await
+    }
+
     /// Check all mint quotes
     /// If quote is paid, wallet will mint
     #[instrument(skip(self))]
@@ -1205,25 +1224,177 @@ impl MultiMintWallet {
         Ok(total_amount)
     }
 
-    /// Mint a specific quote
+    /// Set the active mint for NpubCash integration
+    ///
+    /// This method sets the active mint for NpubCash in the key-value store.
+    /// Since all wallets share the same seed (and thus the same Nostr identity),
+    /// only one mint should be active for NpubCash at a time to avoid conflicts.
+    #[cfg(feature = "npubcash")]
     #[instrument(skip(self))]
-    pub async fn mint(
+    pub async fn set_active_npubcash_mint(&self, mint_url: MintUrl) -> Result<(), Error> {
+        use super::npubcash::{ACTIVE_MINT_KEY, NPUBCASH_KV_NAMESPACE};
+
+        self.localstore
+            .kv_write(
+                NPUBCASH_KV_NAMESPACE,
+                "",
+                ACTIVE_MINT_KEY,
+                mint_url.to_string().as_bytes(),
+            )
+            .await?;
+
+        Ok(())
+    }
+
+    /// Get the active mint for NpubCash integration
+    ///
+    /// Returns the currently active mint URL from the key-value store, if any.
+    #[cfg(feature = "npubcash")]
+    #[instrument(skip(self))]
+    pub async fn get_active_npubcash_mint(&self) -> Result<Option<MintUrl>, Error> {
+        use super::npubcash::{ACTIVE_MINT_KEY, NPUBCASH_KV_NAMESPACE};
+
+        let value = self
+            .localstore
+            .kv_read(NPUBCASH_KV_NAMESPACE, "", ACTIVE_MINT_KEY)
+            .await?;
+
+        match value {
+            Some(bytes) => {
+                let url_str = String::from_utf8(bytes)
+                    .map_err(|_| Error::Custom("Invalid UTF-8 in active mint URL".into()))?;
+                let mint_url = MintUrl::from_str(&url_str)?;
+                Ok(Some(mint_url))
+            }
+            None => Ok(None),
+        }
+    }
+
+    /// Enable NpubCash integration on a specific mint
+    ///
+    /// This sets up NpubCash authentication and registers the mint URL with the
+    /// NpubCash server. It also sets this mint as the active NpubCash mint.
+    #[cfg(feature = "npubcash")]
+    #[instrument(skip(self))]
+    pub async fn enable_npubcash(
+        &self,
+        mint_url: MintUrl,
+        npubcash_url: String,
+    ) -> Result<(), Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.enable_npubcash(npubcash_url).await?;
+        drop(wallets);
+
+        self.set_active_npubcash_mint(mint_url).await?;
+        Ok(())
+    }
+
+    /// Get the Nostr keys used for NpubCash authentication
+    ///
+    /// Since all wallets share the same seed, they all have the same Nostr identity.
+    /// This returns the keys from any wallet in the MultiMintWallet.
+    #[cfg(feature = "npubcash")]
+    pub async fn get_npubcash_keys(&self) -> Result<nostr_sdk::Keys, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.values().next().ok_or(Error::Custom(
+            "No wallets available to get NpubCash keys".into(),
+        ))?;
+        wallet.get_npubcash_keys()
+    }
+
+    /// Sync quotes from NpubCash for the active mint
+    ///
+    /// Fetches quotes from the NpubCash server and filters them to only return
+    /// quotes for the currently active mint.
+    #[cfg(feature = "npubcash")]
+    #[instrument(skip(self))]
+    pub async fn sync_npubcash_quotes(
+        &self,
+    ) -> Result<Vec<crate::wallet::types::MintQuote>, Error> {
+        let active_mint = self
+            .get_active_npubcash_mint()
+            .await?
+            .ok_or(Error::Custom("No active NpubCash mint set".into()))?;
+
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(&active_mint).ok_or(Error::UnknownMint {
+            mint_url: active_mint.to_string(),
+        })?;
+
+        let all_quotes = wallet.sync_npubcash_quotes().await?;
+
+        // Filter to only quotes for the active mint
+        let filtered_quotes: Vec<_> = all_quotes
+            .into_iter()
+            .filter(|q| q.mint_url == active_mint)
+            .collect();
+
+        Ok(filtered_quotes)
+    }
+
+    /// Mint ecash from a paid NpubCash quote
+    ///
+    /// This mints ecash from a quote on the active NpubCash mint.
+    #[cfg(feature = "npubcash")]
+    #[instrument(skip(self))]
+    pub async fn mint_npubcash_quote(
         &self,
-        mint_url: &MintUrl,
         quote_id: &str,
-        conditions: Option<SpendingConditions>,
+        split_target: SplitTarget,
     ) -> Result<Proofs, Error> {
+        let active_mint = self
+            .get_active_npubcash_mint()
+            .await?
+            .ok_or(Error::Custom("No active NpubCash mint set".into()))?;
+
         let wallets = self.wallets.read().await;
-        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
-            mint_url: mint_url.to_string(),
+        let wallet = wallets.get(&active_mint).ok_or(Error::UnknownMint {
+            mint_url: active_mint.to_string(),
         })?;
 
-        wallet
-            .mint(quote_id, SplitTarget::default(), conditions)
-            .await
+        wallet.mint(quote_id, split_target, None).await
+    }
+
+    /// Create a stream that continuously polls NpubCash and yields proofs as payments arrive
+    ///
+    /// This provides a reactive way to handle incoming NpubCash payments. The stream will:
+    /// 1. Poll NpubCash for new paid quotes
+    /// 2. Automatically mint them using the active mint
+    /// 3. Yield the result (MintQuote, Proofs)
+    ///
+    /// # Arguments
+    ///
+    /// * `split_target` - How to split the minted proofs
+    /// * `spending_conditions` - Optional spending conditions for the minted proofs
+    /// * `poll_interval` - How often to check for new quotes
+    #[cfg(feature = "npubcash")]
+    pub fn npubcash_proof_stream(
+        &self,
+        split_target: SplitTarget,
+        spending_conditions: Option<SpendingConditions>,
+        poll_interval: std::time::Duration,
+    ) -> crate::wallet::streams::npubcash::NpubCashProofStream {
+        crate::wallet::streams::npubcash::NpubCashProofStream::new(
+            self.clone(),
+            poll_interval,
+            split_target,
+            spending_conditions,
+        )
     }
 
     /// Wait for a mint quote to be paid and automatically mint the proofs
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint URL where the quote was created
+    /// * `quote_id` - The quote ID to wait for
+    /// * `split_target` - How to split the minted proofs
+    /// * `spending_conditions` - Optional spending conditions for the minted proofs
+    /// * `timeout` - Maximum time to wait for the quote to be paid
     #[cfg(not(target_arch = "wasm32"))]
     #[instrument(skip(self))]
     pub async fn wait_for_mint_quote(
@@ -1231,8 +1402,8 @@ impl MultiMintWallet {
         mint_url: &MintUrl,
         quote_id: &str,
         split_target: SplitTarget,
-        conditions: Option<SpendingConditions>,
-        timeout_secs: u64,
+        spending_conditions: Option<SpendingConditions>,
+        timeout: std::time::Duration,
     ) -> Result<Proofs, Error> {
         let wallets = self.wallets.read().await;
         let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
@@ -1248,9 +1419,8 @@ impl MultiMintWallet {
             .ok_or(Error::UnknownQuote)?;
 
         // Wait for the quote to be paid and mint the proofs
-        let timeout_duration = tokio::time::Duration::from_secs(timeout_secs);
         wallet
-            .wait_and_mint_quote(quote, split_target, conditions, timeout_duration)
+            .wait_and_mint_quote(quote, split_target, spending_conditions, timeout)
             .await
     }
 

+ 300 - 0
crates/cdk/src/wallet/npubcash.rs

@@ -0,0 +1,300 @@
+//! NpubCash integration for CDK Wallet
+//!
+//! This module provides integration between the CDK wallet and the NpubCash service,
+//! allowing wallets to sync quotes, subscribe to updates, and manage NpubCash settings.
+
+use std::sync::Arc;
+
+use cdk_npubcash::{JwtAuthProvider, NpubCashClient, Quote};
+use tracing::instrument;
+
+use crate::error::Error;
+use crate::nuts::SecretKey;
+use crate::wallet::types::{MintQuote, TransactionDirection};
+use crate::wallet::Wallet;
+
+/// KV store namespace for npubcash-related data
+pub const NPUBCASH_KV_NAMESPACE: &str = "npubcash";
+/// KV store key for the last fetch timestamp (stored as u64 Unix timestamp)
+const LAST_FETCH_TIMESTAMP_KEY: &str = "last_fetch_timestamp";
+/// KV store key for the active mint URL
+pub const ACTIVE_MINT_KEY: &str = "active_mint";
+
+impl Wallet {
+    /// Enable NpubCash integration for this wallet
+    ///
+    /// # Arguments
+    ///
+    /// * `npubcash_url` - Base URL of the NpubCash service (e.g., "<https://npubx.cash>")
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the NpubCash client cannot be initialized
+    #[instrument(skip(self))]
+    pub async fn enable_npubcash(&self, npubcash_url: String) -> Result<(), Error> {
+        let keys = self.derive_npubcash_keys()?;
+        let auth_provider = Arc::new(JwtAuthProvider::new(npubcash_url.clone(), keys));
+        let client = Arc::new(NpubCashClient::new(npubcash_url.clone(), auth_provider));
+
+        let mut npubcash = self.npubcash_client.write().await;
+        *npubcash = Some(client.clone());
+        drop(npubcash);
+
+        tracing::info!("NpubCash integration enabled");
+
+        // Automatically set the mint URL on the NpubCash server
+        let mint_url = self.mint_url.to_string();
+        match client.set_mint_url(&mint_url).await {
+            Ok(_) => {
+                tracing::info!(
+                    "Mint URL '{}' set on NpubCash server at '{}'",
+                    mint_url,
+                    npubcash_url
+                );
+            }
+            Err(e) => {
+                tracing::warn!(
+                    "Failed to set mint URL on NpubCash server: {}. Quotes may use server default.",
+                    e
+                );
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Derive Nostr keys from wallet seed for NpubCash authentication
+    ///
+    /// This uses the first 32 bytes of the wallet seed to derive a Nostr keypair.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the key derivation fails
+    fn derive_npubcash_keys(&self) -> Result<nostr_sdk::Keys, Error> {
+        use nostr_sdk::SecretKey;
+
+        let secret_key = SecretKey::from_slice(&self.seed[..32])
+            .map_err(|e| Error::Custom(format!("Failed to derive Nostr keys: {}", e)))?;
+
+        Ok(nostr_sdk::Keys::new(secret_key))
+    }
+
+    /// Get the Nostr keys used for NpubCash authentication
+    ///
+    /// Returns the derived Nostr keys from the wallet seed.
+    /// These keys are used for authenticating with the NpubCash service.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the key derivation fails
+    pub fn get_npubcash_keys(&self) -> Result<nostr_sdk::Keys, Error> {
+        self.derive_npubcash_keys()
+    }
+
+    /// Helper to get NpubCash client reference
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if NpubCash is not enabled
+    async fn get_npubcash_client(&self) -> Result<Arc<NpubCashClient>, Error> {
+        self.npubcash_client
+            .read()
+            .await
+            .clone()
+            .ok_or_else(|| Error::Custom("NpubCash not enabled".to_string()))
+    }
+
+    /// Helper to process npubcash quotes and add them to the wallet
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if adding quotes fails
+    async fn process_npubcash_quotes(&self, quotes: Vec<Quote>) -> Result<Vec<MintQuote>, Error> {
+        let mut mint_quotes = Vec::with_capacity(quotes.len());
+        for quote in quotes {
+            if let Some(mint_quote) = self.add_npubcash_mint_quote(quote).await? {
+                mint_quotes.push(mint_quote);
+            }
+        }
+        Ok(mint_quotes)
+    }
+
+    /// Sync quotes from NpubCash and add them to the wallet
+    ///
+    /// This method fetches quotes from the last stored fetch timestamp and updates
+    /// the timestamp after successful fetch. If no timestamp is stored, it fetches
+    /// all quotes.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if NpubCash is not enabled or the sync fails
+    #[instrument(skip(self))]
+    pub async fn sync_npubcash_quotes(&self) -> Result<Vec<MintQuote>, Error> {
+        let client = self.get_npubcash_client().await?;
+
+        // Get the last fetch timestamp from KV store
+        let since = self.get_last_npubcash_fetch_timestamp().await?;
+
+        let quotes = client
+            .get_quotes(since)
+            .await
+            .map_err(|e| Error::Custom(format!("Failed to sync quotes: {}", e)))?;
+
+        // Update the last fetch timestamp to the max created_at from fetched quotes
+        if let Some(max_ts) = quotes.iter().map(|q| q.created_at).max() {
+            self.set_last_npubcash_fetch_timestamp(max_ts).await?;
+        }
+
+        self.process_npubcash_quotes(quotes).await
+    }
+
+    /// Sync quotes from NpubCash since a specific timestamp and add them to the wallet
+    ///
+    /// # Arguments
+    ///
+    /// * `since` - Unix timestamp to fetch quotes from
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if NpubCash is not enabled or the sync fails
+    #[instrument(skip(self))]
+    pub async fn sync_npubcash_quotes_since(&self, since: u64) -> Result<Vec<MintQuote>, Error> {
+        let client = self.get_npubcash_client().await?;
+        let quotes = client
+            .get_quotes(Some(since))
+            .await
+            .map_err(|e| Error::Custom(format!("Failed to sync quotes: {}", e)))?;
+        self.process_npubcash_quotes(quotes).await
+    }
+
+    /// Create a stream that continuously polls NpubCash and yields proofs as payments arrive
+    ///
+    /// # Arguments
+    ///
+    /// * `split_target` - How to split the minted proofs
+    /// * `spending_conditions` - Optional spending conditions for the minted proofs
+    /// * `poll_interval` - How often to check for new quotes
+    pub fn npubcash_proof_stream(
+        &self,
+        split_target: cdk_common::amount::SplitTarget,
+        spending_conditions: Option<crate::nuts::SpendingConditions>,
+        poll_interval: std::time::Duration,
+    ) -> crate::wallet::streams::npubcash::WalletNpubCashProofStream {
+        crate::wallet::streams::npubcash::WalletNpubCashProofStream::new(
+            self.clone(),
+            poll_interval,
+            split_target,
+            spending_conditions,
+        )
+    }
+
+    /// Set the mint URL in NpubCash settings
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint URL to set
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if NpubCash is not enabled or the update fails
+    #[instrument(skip(self, mint_url))]
+    pub async fn set_npubcash_mint_url(
+        &self,
+        mint_url: impl Into<String>,
+    ) -> Result<cdk_npubcash::UserResponse, Error> {
+        let client = self.get_npubcash_client().await?;
+        client
+            .set_mint_url(mint_url)
+            .await
+            .map_err(|e| Error::Custom(e.to_string()))
+    }
+
+    /// Add an NpubCash quote to the wallet's mint quote database
+    ///
+    /// Converts an NpubCash quote to a wallet MintQuote and stores it using the
+    /// NpubCash-derived secret key for signing.
+    ///
+    /// # Arguments
+    ///
+    /// * `npubcash_quote` - The NpubCash quote to add
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the conversion fails or the database operation fails
+    #[instrument(skip(self))]
+    pub async fn add_npubcash_mint_quote(
+        &self,
+        npubcash_quote: cdk_npubcash::Quote,
+    ) -> Result<Option<MintQuote>, Error> {
+        let npubcash_keys = self.derive_npubcash_keys()?;
+        let secret_key = SecretKey::from_slice(&npubcash_keys.secret_key().to_secret_bytes())
+            .map_err(|e| Error::Custom(format!("Failed to convert secret key: {}", e)))?;
+
+        let mut mint_quote: MintQuote = npubcash_quote.into();
+        mint_quote.secret_key = Some(secret_key);
+
+        let exists = self
+            .list_transactions(Some(TransactionDirection::Incoming))
+            .await?
+            .iter()
+            .any(|tx| tx.quote_id.as_ref() == Some(&mint_quote.id));
+
+        if exists {
+            return Ok(None);
+        }
+
+        self.localstore.add_mint_quote(mint_quote.clone()).await?;
+
+        tracing::info!("Added NpubCash quote {} to wallet database", mint_quote.id);
+        Ok(Some(mint_quote))
+    }
+
+    /// Get reference to the NpubCash client if enabled
+    pub async fn npubcash_client(&self) -> Option<Arc<NpubCashClient>> {
+        self.npubcash_client.read().await.clone()
+    }
+
+    /// Check if NpubCash is enabled for this wallet
+    pub async fn is_npubcash_enabled(&self) -> bool {
+        self.npubcash_client.read().await.is_some()
+    }
+
+    /// Get the last fetch timestamp from KV store
+    ///
+    /// Returns the Unix timestamp of the last successful npubcash fetch,
+    /// or `None` if no fetch has been recorded yet.
+    async fn get_last_npubcash_fetch_timestamp(&self) -> Result<Option<u64>, Error> {
+        let value = self
+            .localstore
+            .kv_read(NPUBCASH_KV_NAMESPACE, "", LAST_FETCH_TIMESTAMP_KEY)
+            .await?;
+
+        match value {
+            Some(bytes) => {
+                let timestamp =
+                    u64::from_be_bytes(bytes.try_into().map_err(|_| {
+                        Error::Custom("Invalid timestamp format in KV store".into())
+                    })?);
+                Ok(Some(timestamp))
+            }
+            None => Ok(None),
+        }
+    }
+
+    /// Store the last fetch timestamp in KV store
+    ///
+    /// # Arguments
+    ///
+    /// * `timestamp` - Unix timestamp of the fetch
+    async fn set_last_npubcash_fetch_timestamp(&self, timestamp: u64) -> Result<(), Error> {
+        self.localstore
+            .kv_write(
+                NPUBCASH_KV_NAMESPACE,
+                "",
+                LAST_FETCH_TIMESTAMP_KEY,
+                &timestamp.to_be_bytes(),
+            )
+            .await?;
+        Ok(())
+    }
+}

+ 3 - 0
crates/cdk/src/wallet/streams/mod.rs

@@ -14,6 +14,9 @@ pub mod payment;
 pub mod proof;
 mod wait;
 
+#[cfg(feature = "npubcash")]
+pub mod npubcash;
+
 /// Shared type
 type RecvFuture<'a, Ret> = Pin<Box<dyn Future<Output = Ret> + Send + 'a>>;
 

+ 186 - 0
crates/cdk/src/wallet/streams/npubcash.rs

@@ -0,0 +1,186 @@
+//! NpubCash Proof Stream
+//!
+//! This stream continuously polls NpubCash for new paid quotes and yields them as proofs.
+
+use std::pin::Pin;
+use std::task::{Context, Poll};
+use std::time::Duration;
+
+use cdk_common::amount::SplitTarget;
+use cdk_common::MintQuoteState;
+use futures::Stream;
+use tokio::sync::mpsc;
+use tokio_util::sync::CancellationToken;
+
+use crate::error::Error;
+use crate::nuts::{Proofs, SpendingConditions};
+use crate::wallet::multi_mint_wallet::MultiMintWallet;
+use crate::wallet::types::MintQuote;
+use crate::wallet::Wallet;
+
+/// Stream that continuously polls NpubCash and yields proofs as payments arrive
+#[allow(missing_debug_implementations)]
+pub struct NpubCashProofStream {
+    rx: mpsc::Receiver<Result<(MintQuote, Proofs), Error>>,
+    cancel: CancellationToken,
+}
+
+impl NpubCashProofStream {
+    /// Create a new NpubCash proof stream
+    pub fn new(
+        wallet: MultiMintWallet,
+        poll_interval: Duration,
+        split_target: SplitTarget,
+        spending_conditions: Option<SpendingConditions>,
+    ) -> Self {
+        let (tx, rx) = mpsc::channel(32);
+        let cancel = CancellationToken::new();
+        let cancel_clone = cancel.clone();
+
+        tokio::spawn(async move {
+            let mut interval = tokio::time::interval(poll_interval);
+
+            loop {
+                tokio::select! {
+                    _ = cancel_clone.cancelled() => {
+                        break;
+                    }
+                    _ = interval.tick() => {
+                        match wallet.sync_npubcash_quotes().await {
+                            Ok(quotes) => {
+                                for quote in quotes {
+                                    if matches!(quote.state, MintQuoteState::Paid) {
+                                        let quote_id = quote.id.clone();
+                                        let mint_url = quote.mint_url.clone();
+                                        tracing::info!("Minting NpubCash quote {}...", quote_id);
+
+                                        let result = async {
+                                            // Get wallet for this quote's mint
+                                            let wallet_instance = wallet.get_wallet(&mint_url).await.ok_or(Error::UnknownMint {
+                                                mint_url: mint_url.to_string(),
+                                            })?;
+
+                                            let proofs = wallet_instance
+                                                .mint(&quote_id, split_target.clone(), spending_conditions.clone())
+                                                .await?;
+
+                                            Ok((quote.clone(), proofs))
+                                        }.await;
+
+                                        if tx.send(result).await.is_err() {
+                                            return; // Receiver dropped
+                                        }
+                                    }
+                                }
+                            }
+                            Err(e) => {
+                                tracing::warn!("Error syncing NpubCash quotes: {}", e);
+                                // Optional: Send error to stream? Or just log and retry?
+                                // Logging is safer to keep stream alive.
+                            }
+                        }
+                    }
+                }
+            }
+        });
+
+        Self { rx, cancel }
+    }
+}
+
+impl Stream for NpubCashProofStream {
+    type Item = Result<(MintQuote, Proofs), Error>;
+
+    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
+        self.rx.poll_recv(cx)
+    }
+}
+
+impl Drop for NpubCashProofStream {
+    fn drop(&mut self) {
+        self.cancel.cancel();
+    }
+}
+
+/// Stream that continuously polls NpubCash and yields proofs for a single Wallet
+#[allow(missing_debug_implementations)]
+pub struct WalletNpubCashProofStream {
+    rx: mpsc::Receiver<Result<(MintQuote, Proofs), Error>>,
+    cancel: CancellationToken,
+}
+
+impl WalletNpubCashProofStream {
+    /// Create a new NpubCash proof stream for a single wallet
+    pub fn new(
+        wallet: Wallet,
+        poll_interval: Duration,
+        split_target: SplitTarget,
+        spending_conditions: Option<SpendingConditions>,
+    ) -> Self {
+        let (tx, rx) = mpsc::channel(32);
+        let cancel = CancellationToken::new();
+        let cancel_clone = cancel.clone();
+
+        tokio::spawn(async move {
+            let mut interval = tokio::time::interval(poll_interval);
+
+            loop {
+                tokio::select! {
+                    _ = cancel_clone.cancelled() => {
+                        break;
+                    }
+                    _ = interval.tick() => {
+                        match wallet.sync_npubcash_quotes().await {
+                            Ok(quotes) => {
+                                for quote in quotes {
+                                    if matches!(quote.state, MintQuoteState::Paid) {
+                                        let quote_id = quote.id.clone();
+                                        let mint_url = quote.mint_url.clone();
+
+                                        // Safety check: ensure the quote is for this wallet's mint
+                                        if mint_url != wallet.mint_url {
+                                            tracing::debug!("Skipping quote {} for different mint {}", quote_id, mint_url);
+                                            continue;
+                                        }
+
+                                        tracing::info!("Minting NpubCash quote {}...", quote_id);
+
+                                        let result = async {
+                                            let proofs = wallet
+                                                .mint(&quote_id, split_target.clone(), spending_conditions.clone())
+                                                .await?;
+                                            Ok((quote.clone(), proofs))
+                                        }.await;
+
+                                        if tx.send(result).await.is_err() {
+                                            return; // Receiver dropped
+                                        }
+                                    }
+                                }
+                            }
+                            Err(e) => {
+                                tracing::warn!("Error syncing NpubCash quotes: {}", e);
+                            }
+                        }
+                    }
+                }
+            }
+        });
+
+        Self { rx, cancel }
+    }
+}
+
+impl Stream for WalletNpubCashProofStream {
+    type Item = Result<(MintQuote, Proofs), Error>;
+
+    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
+        self.rx.poll_recv(cx)
+    }
+}
+
+impl Drop for WalletNpubCashProofStream {
+    fn drop(&mut self) {
+        self.cancel.cancel();
+    }
+}

+ 1 - 0
flake.nix

@@ -351,6 +351,7 @@
           "cdk-mint-rpc" = "-p cdk-mint-rpc";
           "cdk-prometheus" = "-p cdk-prometheus";
           "cdk-ffi" = "-p cdk-ffi";
+          "cdk-npubcash" = "-p cdk-npubcash";
 
           # Binaries: cdk-cli
           "cdk-cli" = "-p cdk-cli";