Forráskód Böngészése

chore: move `pay_request` logic into cdk lib (#1028)

* pay request into cdk lib
lollerfirst 1 hónapja
szülő
commit
4f65441c0d

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

@@ -20,7 +20,7 @@ redb = ["dep:cdk-redb"]
 anyhow.workspace = true
 bip39.workspace = true
 bitcoin.workspace = true
-cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "bip353"]}
+cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "nostr", "bip353"]}
 cdk-redb = { workspace = true, features = ["wallet"], optional = true }
 cdk-sqlite = { workspace = true, features = ["wallet"] }
 clap.workspace = true

+ 21 - 242
crates/cdk-cli/src/sub_commands/create_request.rs

@@ -1,16 +1,6 @@
-use std::str::FromStr;
-
-use anyhow::{bail, Result};
-use bitcoin::hashes::sha256::Hash as Sha256Hash;
-use cdk::nuts::nut01::PublicKey;
-use cdk::nuts::nut11::{Conditions, SigFlag, SpendingConditions};
-use cdk::nuts::nut18::{Nut10SecretRequest, TransportType};
-use cdk::nuts::{CurrencyUnit, PaymentRequest, PaymentRequestPayload, Token, Transport};
-use cdk::wallet::{MultiMintWallet, ReceiveOptions};
+use anyhow::Result;
+use cdk::wallet::{payment_request as pr, MultiMintWallet};
 use clap::Args;
-use nostr_sdk::nips::nip19::Nip19Profile;
-use nostr_sdk::prelude::*;
-use nostr_sdk::{Client as NostrClient, Filter, Keys, ToBech32};
 
 #[derive(Args)]
 pub struct CreateRequestSubCommand {
@@ -55,241 +45,30 @@ pub async fn create_request(
     multi_mint_wallet: &MultiMintWallet,
     sub_command_args: &CreateRequestSubCommand,
 ) -> Result<()> {
-    // Get available mints from the wallet
-    let mints: Vec<cdk::mint_url::MintUrl> = multi_mint_wallet
-        .get_balances(&CurrencyUnit::Sat)
-        .await?
-        .keys()
-        .cloned()
-        .collect();
-
-    // Process transport based on command line args
-    let transport_type = sub_command_args.transport.to_lowercase();
-    let transports = match transport_type.as_str() {
-        "nostr" => {
-            let keys = Keys::generate();
-
-            // Use custom relays if provided, otherwise use defaults
-            let relays = if let Some(custom_relays) = &sub_command_args.nostr_relay {
-                if !custom_relays.is_empty() {
-                    println!("Using custom Nostr relays: {custom_relays:?}");
-                    custom_relays.clone()
-                } else {
-                    // Empty vector provided, fall back to defaults
-                    vec![
-                        "wss://relay.nos.social".to_string(),
-                        "wss://relay.damus.io".to_string(),
-                    ]
-                }
-            } else {
-                // No relays provided, use defaults
-                vec![
-                    "wss://relay.nos.social".to_string(),
-                    "wss://relay.damus.io".to_string(),
-                ]
-            };
-
-            let nprofile = Nip19Profile::new(keys.public_key, relays.clone())?;
-
-            let nostr_transport = Transport {
-                _type: TransportType::Nostr,
-                target: nprofile.to_bech32()?,
-                tags: Some(vec![vec!["n".to_string(), "17".to_string()]]),
-            };
-
-            // We'll need the Nostr keys and relays later for listening
-            let transport_info = Some((keys, relays, nprofile.public_key));
-
-            (vec![nostr_transport], transport_info)
-        }
-        "http" => {
-            if let Some(url) = &sub_command_args.http_url {
-                let http_transport = Transport {
-                    _type: TransportType::HttpPost,
-                    target: url.clone(),
-                    tags: None,
-                };
-
-                (vec![http_transport], None)
-            } else {
-                println!(
-                    "Warning: HTTP transport selected but no URL provided, skipping transport"
-                );
-                (vec![], None)
-            }
-        }
-        "none" => (vec![], None),
-        _ => {
-            println!("Warning: Unknown transport type '{transport_type}', defaulting to none");
-            (vec![], None)
-        }
-    };
-
-    // Create spending conditions based on provided arguments
-    // Handle the following cases:
-    // 1. Only P2PK condition
-    // 2. Only HTLC condition with hash
-    // 3. Only HTLC condition with preimage
-    // 4. Both P2PK and HTLC conditions
-
-    let spending_conditions = if let Some(pubkey_strings) = &sub_command_args.pubkey {
-        // Parse all pubkeys
-        let mut parsed_pubkeys = Vec::new();
-        for pubkey_str in pubkey_strings {
-            match PublicKey::from_str(pubkey_str) {
-                Ok(pubkey) => parsed_pubkeys.push(pubkey),
-                Err(err) => {
-                    println!("Error parsing pubkey {pubkey_str}: {err}");
-                    // Continue with other pubkeys
-                }
-            }
-        }
-
-        if parsed_pubkeys.is_empty() {
-            println!("No valid pubkeys provided");
-            None
-        } else {
-            // We have pubkeys for P2PK condition
-            let num_sigs = sub_command_args.num_sigs.min(parsed_pubkeys.len() as u64);
-
-            // Check if we also have an HTLC condition
-            if let Some(hash_str) = &sub_command_args.hash {
-                // Create conditions with the pubkeys
-                let conditions = Conditions {
-                    locktime: None,
-                    pubkeys: Some(parsed_pubkeys),
-                    refund_keys: None,
-                    num_sigs: Some(num_sigs),
-                    sig_flag: SigFlag::SigInputs,
-                    num_sigs_refund: None,
-                };
-
-                // Try to parse the hash
-                match Sha256Hash::from_str(hash_str) {
-                    Ok(hash) => {
-                        // Create HTLC condition with P2PK in the conditions
-                        Some(SpendingConditions::HTLCConditions {
-                            data: hash,
-                            conditions: Some(conditions),
-                        })
-                    }
-                    Err(err) => {
-                        println!("Error parsing hash: {err}");
-                        // Fallback to just P2PK with multiple pubkeys
-                        bail!("Error parsing hash");
-                    }
-                }
-            } else if let Some(preimage) = &sub_command_args.preimage {
-                // Create conditions with the pubkeys
-                let conditions = Conditions {
-                    locktime: None,
-                    pubkeys: Some(parsed_pubkeys),
-                    refund_keys: None,
-                    num_sigs: Some(num_sigs),
-                    sig_flag: SigFlag::SigInputs,
-                    num_sigs_refund: None,
-                };
-
-                // Create HTLC conditions with the hash and pubkeys in conditions
-                Some(SpendingConditions::new_htlc(
-                    preimage.to_string(),
-                    Some(conditions),
-                )?)
-            } else {
-                // Only P2PK condition with multiple pubkeys
-                Some(SpendingConditions::new_p2pk(
-                    *parsed_pubkeys.first().unwrap(),
-                    Some(Conditions {
-                        locktime: None,
-                        pubkeys: Some(parsed_pubkeys[1..].to_vec()),
-                        refund_keys: None,
-                        num_sigs: Some(num_sigs),
-                        sig_flag: SigFlag::SigInputs,
-                        num_sigs_refund: None,
-                    }),
-                ))
-            }
-        }
-    } else if let Some(hash_str) = &sub_command_args.hash {
-        // Only HTLC condition with provided hash
-        match Sha256Hash::from_str(hash_str) {
-            Ok(hash) => Some(SpendingConditions::HTLCConditions {
-                data: hash,
-                conditions: None,
-            }),
-            Err(err) => {
-                println!("Error parsing hash: {err}");
-                None
-            }
-        }
-    } else if let Some(preimage) = &sub_command_args.preimage {
-        // Only HTLC condition with provided preimage
-        // For HTLC, create the hash from the preimage and use it directly
-        Some(SpendingConditions::new_htlc(preimage.to_string(), None)?)
-    } else {
-        None
-    };
-
-    // Convert SpendingConditions to Nut10SecretRequest
-    let nut10 = spending_conditions.map(Nut10SecretRequest::from);
-
-    // Extract the transports option from our match result
-    let (transports, nostr_info) = transports;
-
-    let req = PaymentRequest {
-        payment_id: None,
-        amount: sub_command_args.amount.map(|a| a.into()),
-        unit: Some(CurrencyUnit::from_str(&sub_command_args.unit)?),
-        single_use: Some(true),
-        mints: Some(mints),
+    // Gather parameters for library call
+    let params = pr::CreateRequestParams {
+        amount: sub_command_args.amount,
+        unit: sub_command_args.unit.clone(),
         description: sub_command_args.description.clone(),
-        transports,
-        nut10,
+        pubkeys: sub_command_args.pubkey.clone(),
+        num_sigs: sub_command_args.num_sigs,
+        hash: sub_command_args.hash.clone(),
+        preimage: sub_command_args.preimage.clone(),
+        transport: sub_command_args.transport.to_lowercase(),
+        http_url: sub_command_args.http_url.clone(),
+        nostr_relays: sub_command_args.nostr_relay.clone(),
     };
 
-    // Always print the request
-    println!("{req}");
+    let (req, nostr_wait) = multi_mint_wallet.create_request(params).await?;
 
-    // Only listen for Nostr payment if Nostr transport was selected
-    if let Some((keys, relays, pubkey)) = nostr_info {
-        println!("Listening for payment via Nostr...");
+    // Print the request to stdout
+    println!("{}", req);
 
-        let client = NostrClient::new(keys);
-        let filter = Filter::new().pubkey(pubkey);
-
-        for relay in relays {
-            client.add_read_relay(relay).await?;
-        }
-
-        client.connect().await;
-        client.subscribe(filter, None).await?;
-
-        // Handle subscription notifications with `handle_notifications` method
-        client
-            .handle_notifications(|notification| async {
-                let mut exit = false;
-                if let RelayPoolNotification::Event {
-                    subscription_id: _,
-                    event,
-                    ..
-                } = notification
-                {
-                    let unwrapped = client.unwrap_gift_wrap(&event).await?;
-                    let rumor = unwrapped.rumor;
-                    let payload: PaymentRequestPayload = serde_json::from_str(&rumor.content)?;
-                    let token =
-                        Token::new(payload.mint, payload.proofs, payload.memo, payload.unit);
-
-                    let amount = multi_mint_wallet
-                        .receive(&token.to_string(), ReceiveOptions::default())
-                        .await?;
-
-                    println!("Received {amount}");
-                    exit = true;
-                }
-                Ok(exit) // Set to true to exit from the loop
-            })
-            .await?;
+    // If we set up Nostr transport, optionally wait for payment and receive it
+    if let Some(info) = nostr_wait {
+        println!("Listening for payment via Nostr...");
+        let amount = multi_mint_wallet.wait_for_nostr_payment(info).await?;
+        println!("Received {}", amount);
     }
 
     Ok(())

+ 12 - 134
crates/cdk-cli/src/sub_commands/pay_request.rs

@@ -1,13 +1,10 @@
 use std::io::{self, Write};
 
 use anyhow::{anyhow, Result};
-use cdk::nuts::nut18::TransportType;
-use cdk::nuts::{PaymentRequest, PaymentRequestPayload, Token};
-use cdk::wallet::{MultiMintWallet, SendOptions};
+use cdk::nuts::PaymentRequest;
+use cdk::wallet::MultiMintWallet;
+use cdk::Amount;
 use clap::Args;
-use nostr_sdk::nips::nip19::Nip19Profile;
-use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys};
-use reqwest::Client;
 
 #[derive(Args)]
 pub struct PayRequestSubCommand {
@@ -22,7 +19,8 @@ pub async fn pay_request(
 
     let unit = &payment_request.unit;
 
-    let amount = match payment_request.amount {
+    // Determine amount: use from request or prompt user
+    let amount: Amount = match payment_request.amount {
         Some(amount) => amount,
         None => {
             println!("Enter the amount you would like to pay");
@@ -65,132 +63,12 @@ pub async fn pay_request(
         }
     }
 
-    let matching_wallet = matching_wallets.first().unwrap();
+    let matching_wallet = matching_wallets
+        .first()
+        .ok_or_else(|| anyhow!("No wallet found that can pay this request"))?;
 
-    if payment_request.transports.is_empty() {
-        return Err(anyhow!("Cannot pay request without transport"));
-    }
-    let transports = payment_request.transports.clone();
-
-    // We prefer nostr transport if it is available to hide ip.
-    let transport = transports
-        .iter()
-        .find(|t| t._type == TransportType::Nostr)
-        .or_else(|| {
-            transports
-                .iter()
-                .find(|t| t._type == TransportType::HttpPost)
-        });
-
-    let prepared_send = matching_wallet
-        .prepare_send(
-            amount,
-            SendOptions {
-                include_fee: true,
-                ..Default::default()
-            },
-        )
-        .await?;
-
-    let token = prepared_send.confirm(None).await?;
-
-    // We need the keysets information to properly convert from token proof to proof
-    let keysets_info = match matching_wallet
-        .localstore
-        .get_mint_keysets(token.mint_url()?)
-        .await?
-    {
-        Some(keysets_info) => keysets_info,
-        None => matching_wallet.load_mint_keysets().await?, // Hit the keysets endpoint if we don't have the keysets for this Mint
-    };
-    let proofs = token.proofs(&keysets_info)?;
-
-    if let Some(transport) = transport {
-        let payload = PaymentRequestPayload {
-            id: payment_request.payment_id.clone(),
-            memo: None,
-            mint: matching_wallet.mint_url.clone(),
-            unit: matching_wallet.unit.clone(),
-            proofs,
-        };
-
-        match transport._type {
-            TransportType::Nostr => {
-                let keys = Keys::generate();
-                let client = NostrClient::new(keys);
-                let nprofile = Nip19Profile::from_bech32(&transport.target)?;
-
-                println!("{:?}", nprofile.relays);
-
-                let rumor = EventBuilder::new(
-                    nostr_sdk::Kind::from_u16(14),
-                    serde_json::to_string(&payload)?,
-                )
-                .build(nprofile.public_key);
-                let relays = nprofile.relays;
-
-                for relay in relays.iter() {
-                    client.add_write_relay(relay).await?;
-                }
-
-                client.connect().await;
-
-                let gift_wrap = client
-                    .gift_wrap_to(relays, &nprofile.public_key, rumor, None)
-                    .await?;
-
-                println!(
-                    "Published event {} succufully to {}",
-                    gift_wrap.val,
-                    gift_wrap
-                        .success
-                        .iter()
-                        .map(|s| s.to_string())
-                        .collect::<Vec<_>>()
-                        .join(", ")
-                );
-
-                if !gift_wrap.failed.is_empty() {
-                    println!(
-                        "Could not publish to {:?}",
-                        gift_wrap
-                            .failed
-                            .keys()
-                            .map(|relay| relay.to_string())
-                            .collect::<Vec<_>>()
-                            .join(", ")
-                    );
-                }
-            }
-
-            TransportType::HttpPost => {
-                let client = Client::new();
-
-                let res = client
-                    .post(transport.target.clone())
-                    .json(&payload)
-                    .send()
-                    .await?;
-
-                let status = res.status();
-                if status.is_success() {
-                    println!("Successfully posted payment");
-                } else {
-                    println!("{res:?}");
-                    println!("Error posting payment");
-                }
-            }
-        }
-    } else {
-        // If no transport is available, print the token
-        let token = Token::new(
-            matching_wallet.mint_url.clone(),
-            proofs,
-            None,
-            matching_wallet.unit.clone(),
-        );
-        println!("Token: {token}");
-    }
-
-    Ok(())
+    matching_wallet
+        .pay_request(payment_request.clone(), Some(amount))
+        .await
+        .map_err(|e| anyhow!(e.to_string()))
 }

+ 7 - 1
crates/cdk/Cargo.toml

@@ -11,8 +11,9 @@ license.workspace = true
 
 
 [features]
-default = ["mint", "wallet", "auth"]
+default = ["mint", "wallet", "auth", "nostr"]
 wallet = ["dep:futures", "dep:reqwest", "cdk-common/wallet", "dep:rustls"]
+nostr = ["wallet", "dep:nostr-sdk"]
 mint = ["dep:futures", "dep:reqwest", "cdk-common/mint", "cdk-signatory"]
 auth = ["dep:jsonwebtoken", "cdk-common/auth", "cdk-common/auth"]
 bip353 = ["dep:trust-dns-resolver"]
@@ -44,6 +45,11 @@ utoipa = { workspace = true, optional = true }
 uuid.workspace = true
 jsonwebtoken = { workspace = true, optional = true }
 trust-dns-resolver = { version = "0.23.2", optional = true }
+nostr-sdk = { optional = true, version = "0.43.0", default-features = false, features = [
+    "nip04",
+    "nip44",
+    "nip59"
+]}
 cdk-prometheus = {workspace = true, optional = true}
 web-time.workspace = true
 # -Z minimal-versions

+ 3 - 0
crates/cdk/src/lib.rs

@@ -68,3 +68,6 @@ pub type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
 /// Re-export futures::Stream
 #[cfg(any(feature = "wallet", feature = "mint"))]
 pub use futures::{Stream, StreamExt};
+/// Payment Request
+#[cfg(feature = "wallet")]
+pub use wallet::payment_request;

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

@@ -39,6 +39,7 @@ mod keysets;
 mod melt;
 mod mint_connector;
 pub mod multi_mint_wallet;
+pub mod payment_request;
 mod proofs;
 mod receive;
 mod send;

+ 626 - 0
crates/cdk/src/wallet/payment_request.rs

@@ -0,0 +1,626 @@
+//! Utilities for paying NUT-18 Payment Requests.
+//!
+//! This module prepares and broadcasts payments for Cashu NUT-18 payment requests using either
+//! Nostr or HTTP transports when available. If no transport is present in the request, an error
+//! is returned so callers can handle alternative delivery mechanisms explicitly.
+
+use std::str::FromStr;
+
+use anyhow::Result;
+use bitcoin::hashes::sha256::Hash as Sha256Hash;
+use cdk_common::{Amount, PaymentRequest, PaymentRequestPayload, TransportType};
+#[cfg(feature = "nostr")]
+use nostr_sdk::nips::nip19::Nip19Profile;
+#[cfg(feature = "nostr")]
+use nostr_sdk::prelude::*;
+#[cfg(feature = "nostr")]
+use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys, ToBech32};
+use reqwest::Client;
+
+use crate::error::Error;
+use crate::nuts::nut11::{Conditions, SigFlag, SpendingConditions};
+use crate::nuts::nut18::Nut10SecretRequest;
+use crate::nuts::{CurrencyUnit, Transport};
+#[cfg(feature = "nostr")]
+use crate::wallet::ReceiveOptions;
+use crate::wallet::{MultiMintWallet, SendOptions};
+use crate::Wallet;
+
+impl Wallet {
+    /// Pay a NUT-18 PaymentRequest using a specific wallet.
+    ///
+    /// - If the request contains a Nostr or HttpPost transport, it will try those (preferring Nostr).
+    /// - If no usable transport is present, this returns an error.
+    /// - If the request has no amount, a `custom_amount` must be provided.
+    pub async fn pay_request(
+        &self,
+        payment_request: PaymentRequest,
+        custom_amount: Option<Amount>,
+    ) -> Result<(), Error> {
+        let amount = match payment_request.amount {
+            Some(amount) => amount,
+            None => match custom_amount {
+                Some(a) => a,
+                None => return Err(Error::AmountUndefined),
+            },
+        };
+
+        let transports = payment_request.transports.clone();
+
+        // Prefer Nostr to avoid revealing IP, fall back to HTTP POST.
+        let transport = transports
+            .iter()
+            .find(|t| t._type == TransportType::Nostr)
+            .or_else(|| {
+                transports
+                    .iter()
+                    .find(|t| t._type == TransportType::HttpPost)
+            });
+
+        let prepared_send = self
+            .prepare_send(
+                amount,
+                SendOptions {
+                    include_fee: true,
+                    ..Default::default()
+                },
+            )
+            .await?;
+
+        let token = prepared_send.confirm(None).await?;
+
+        // We need the keysets information to properly convert from token proof to proof
+        let keysets_info = match self.localstore.get_mint_keysets(token.mint_url()?).await? {
+            Some(keysets_info) => keysets_info,
+            None => self.load_mint_keysets().await?,
+        };
+        let proofs = token.proofs(&keysets_info)?;
+
+        if let Some(transport) = transport {
+            let payload = PaymentRequestPayload {
+                id: payment_request.payment_id.clone(),
+                memo: None,
+                mint: self.mint_url.clone(),
+                unit: self.unit.clone(),
+                proofs,
+            };
+
+            match transport._type {
+                TransportType::Nostr => {
+                    #[cfg(feature = "nostr")]
+                    {
+                        let keys = Keys::generate();
+                        let client = NostrClient::new(keys);
+                        let nprofile = Nip19Profile::from_bech32(&transport.target)
+                            .map_err(|e| Error::Custom(format!("Invalid nprofile: {e}")))?;
+
+                        let rumor = EventBuilder::new(
+                            nostr_sdk::Kind::from_u16(14),
+                            serde_json::to_string(&payload)
+                                .map_err(|e| Error::Custom(format!("Serialize payload: {e}")))?,
+                        )
+                        .build(nprofile.public_key);
+                        let relays = nprofile.relays;
+
+                        for relay in relays.iter() {
+                            client
+                                .add_write_relay(relay)
+                                .await
+                                .map_err(|e| Error::Custom(format!("Add relay {relay}: {e}")))?;
+                        }
+
+                        client.connect().await;
+
+                        let gift_wrap = client
+                            .gift_wrap_to(relays, &nprofile.public_key, rumor, None)
+                            .await
+                            .map_err(|e| Error::Custom(format!("Publish Nostr event: {e}")))?;
+
+                        println!(
+                            "Published event {} successfully to {}",
+                            gift_wrap.val,
+                            gift_wrap
+                                .success
+                                .iter()
+                                .map(|s| s.to_string())
+                                .collect::<Vec<_>>()
+                                .join(", ")
+                        );
+
+                        if !gift_wrap.failed.is_empty() {
+                            println!(
+                                "Could not publish to {}",
+                                gift_wrap
+                                    .failed
+                                    .keys()
+                                    .map(|relay| relay.to_string())
+                                    .collect::<Vec<_>>()
+                                    .join(", ")
+                            );
+                        }
+
+                        Ok(())
+                    }
+                    #[cfg(not(feature = "nostr"))]
+                    Err(Error::Custom(
+                        "Nostr is not enabled in this build".to_string(),
+                    ))
+                }
+
+                TransportType::HttpPost => {
+                    let client = Client::new();
+
+                    let res = client
+                        .post(transport.target.clone())
+                        .json(&payload)
+                        .send()
+                        .await
+                        .map_err(|e| Error::HttpError(None, e.to_string()))?;
+
+                    let status = res.status();
+                    if status.is_success() {
+                        println!("Successfully posted payment");
+                        Ok(())
+                    } else {
+                        let body = res.text().await.unwrap_or_default();
+                        Err(Error::HttpError(Some(status.as_u16()), body))
+                    }
+                }
+            }
+        } else {
+            // If no transport is available, return an error instead of printing the token
+            Err(Error::Custom(
+                "No transport available in payment request".to_string(),
+            ))
+        }
+    }
+}
+
+/// Parameters for creating a PaymentRequest
+///
+/// This mirrors the CLI inputs and is used by `create_request` to build a
+/// NUT-18 PaymentRequest. When `transport` is set to `nostr`, the function
+/// also returns a `NostrWaitInfo` that can be passed to `wait_for_nostr_payment`.
+#[derive(Debug, Clone)]
+pub struct CreateRequestParams {
+    /// Optional amount to request (in the smallest unit for the chosen currency unit)
+    pub amount: Option<u64>,
+    /// Currency unit string (e.g., "sat")
+    pub unit: String,
+    /// Optional human-readable description for the request
+    pub description: Option<String>,
+    /// Optional set of public keys for P2PK spending conditions (multisig supported)
+    pub pubkeys: Option<Vec<String>>, // multiple P2PK pubkeys
+    /// Required number of signatures if `pubkeys` is provided (defaults typically to 1)
+    pub num_sigs: u64, // required signatures for P2PK
+    /// Optional HTLC hash condition (mutually exclusive with `preimage`)
+    pub hash: Option<String>, // HTLC hash
+    /// Optional HTLC preimage (mutually exclusive with `hash`)
+    pub preimage: Option<String>, // HTLC preimage
+    /// Transport type for the request: "nostr", "http", or "none"
+    pub transport: String, // "nostr", "http", or "none"
+    /// Target URL for HTTP transport (required if `transport == http`)
+    pub http_url: Option<String>, // when transport == http
+    /// List of Nostr relay URLs to include in the nprofile (used if `transport == nostr`)
+    pub nostr_relays: Option<Vec<String>>, // when transport == nostr
+}
+
+/// Extra information needed to wait for an incoming Nostr payment
+///
+/// Returned by `create_request` when the transport is `nostr`. Pass this to
+/// `wait_for_nostr_payment` to connect, subscribe, and receive the incoming
+/// payment on the specified relays.
+#[cfg(feature = "nostr")]
+#[derive(Debug, Clone)]
+pub struct NostrWaitInfo {
+    /// Ephemeral keys used to connect to relays and unwrap the gift-wrapped event
+    pub keys: Keys,
+    /// Nostr relays to read from while waiting for the payment
+    pub relays: Vec<String>,
+    /// The recipient public key to subscribe to for incoming events
+    pub pubkey: nostr_sdk::PublicKey,
+}
+
+impl MultiMintWallet {
+    /// Derive enforceable NUT-10 spending conditions from high-level request params.
+    ///
+    /// Why:
+    /// - Centralizes translation of CLI/SDK inputs (P2PK multisig and HTLC variants) into
+    ///   a single, canonical `SpendingConditions` shape so requests are consistent.
+    /// - Prevents ambiguous construction by capping `num_sigs` to the number of provided keys
+    ///   and rejecting malformed hashes/inputs early.
+    /// - Encourages safe defaults by selecting `SigFlag::SigInputs` and composing conditions
+    ///   that can be verified by recipients and mints.
+    ///
+    /// Behavior notes (rationale):
+    /// - If no P2PK or HTLC data is given, returns `Ok(None)` so callers emit a plain request
+    ///   without additional constraints.
+    /// - With `pubkeys` only, constructs P2PK-style conditions where the first key is used as
+    ///   the primary spend key and the remainder contribute to multisig according to `num_sigs`.
+    /// - With `hash` or `preimage`, constructs an HTLC condition, optionally embedding P2PK
+    ///   conditions to require signatures in addition to the hash lock.
+    ///
+    /// Errors:
+    /// - Invalid SHA-256 `hash` strings or invalid HTLC/P2PK parameterizations surface as errors
+    ///   from parsing and `SpendingConditions` constructors.
+    fn get_pr_spending_conditions(
+        &self,
+        params: &CreateRequestParams,
+    ) -> Result<Option<SpendingConditions>, Error> {
+        // Spending conditions
+        let spending_conditions: Option<SpendingConditions> =
+            if let Some(pubkey_strings) = &params.pubkeys {
+                // parse pubkeys
+                let mut parsed_pubkeys = Vec::new();
+                for p in pubkey_strings {
+                    if let Ok(pk) = crate::nuts::nut01::PublicKey::from_str(p) {
+                        parsed_pubkeys.push(pk);
+                    }
+                }
+
+                if parsed_pubkeys.is_empty() {
+                    None
+                } else {
+                    let num_sigs = params.num_sigs.min(parsed_pubkeys.len() as u64);
+
+                    if let Some(hash_str) = &params.hash {
+                        let conditions = Conditions {
+                            locktime: None,
+                            pubkeys: Some(parsed_pubkeys),
+                            refund_keys: None,
+                            num_sigs: Some(num_sigs),
+                            sig_flag: SigFlag::SigInputs,
+                            num_sigs_refund: None,
+                        };
+
+                        match Sha256Hash::from_str(hash_str) {
+                            Ok(hash) => Some(SpendingConditions::HTLCConditions {
+                                data: hash,
+                                conditions: Some(conditions),
+                            }),
+                            Err(err) => {
+                                return Err(Error::Custom(format!("Error parsing hash: {err}")))
+                            }
+                        }
+                    } else if let Some(preimage) = &params.preimage {
+                        let conditions = Conditions {
+                            locktime: None,
+                            pubkeys: Some(parsed_pubkeys),
+                            refund_keys: None,
+                            num_sigs: Some(num_sigs),
+                            sig_flag: SigFlag::SigInputs,
+                            num_sigs_refund: None,
+                        };
+
+                        Some(SpendingConditions::new_htlc(
+                            preimage.to_string(),
+                            Some(conditions),
+                        )?)
+                    } else {
+                        Some(SpendingConditions::new_p2pk(
+                            *parsed_pubkeys.first().expect("not empty"),
+                            Some(Conditions {
+                                locktime: None,
+                                pubkeys: Some(parsed_pubkeys[1..].to_vec()),
+                                refund_keys: None,
+                                num_sigs: Some(num_sigs),
+                                sig_flag: SigFlag::SigInputs,
+                                num_sigs_refund: None,
+                            }),
+                        ))
+                    }
+                }
+            } else if let Some(hash_str) = &params.hash {
+                match Sha256Hash::from_str(hash_str) {
+                    Ok(hash) => Some(SpendingConditions::HTLCConditions {
+                        data: hash,
+                        conditions: None,
+                    }),
+                    Err(err) => return Err(Error::Custom(format!("Error parsing hash: {err}"))),
+                }
+            } else if let Some(preimage) = &params.preimage {
+                Some(SpendingConditions::new_htlc(preimage.to_string(), None)?)
+            } else {
+                None
+            };
+        Ok(spending_conditions)
+    }
+
+    /// Create a NUT-18 PaymentRequest from high-level parameters.
+    ///
+    /// Why:
+    /// - Ensures the CLI and SDKs construct requests consistently using wallet context.
+    /// - Advertises available mints for the chosen unit so payers can select compatible proofs.
+    /// - Optionally embeds a transport; Nostr is preferred to reduce IP exposure for the payer.
+    ///
+    /// Behavior summary (focus on rationale rather than steps):
+    /// - Uses `unit` to discover mints with balances as a hint to senders (helps route payments without leaking more data than necessary).
+    /// - Translates P2PK/multisig and HTLC inputs (pubkeys/num_sigs/hash/preimage) into a NUT-10 secret request so the receiver can enforce spending constraints.
+    /// - For `transport == "nostr"`, generates ephemeral keys and an nprofile pointing at the chosen relays; returns `NostrWaitInfo` so callers can wait for the incoming payment without coupling construction and reception logic.
+    /// - For `transport == "http"`, attaches the provided endpoint; for `none` or unknown, omits transports to let the caller deliver out-of-band.
+    ///
+    /// Returns:
+    /// - `(PaymentRequest, Some(NostrWaitInfo))` when `transport == "nostr"`.
+    /// - `(PaymentRequest, None)` otherwise.
+    ///
+    /// Errors when:
+    /// - `unit` cannot be parsed, relay URLs are invalid, or P2PK/HTLC parameters are malformed.
+    ///
+    /// Notes:
+    /// - Sets `single_use = true` to discourage replays.
+    /// - Ephemeral Nostr keys are intentional; keep `NostrWaitInfo` only as long as needed for reception.
+    #[cfg(feature = "nostr")]
+    pub async fn create_request(
+        &self,
+        params: CreateRequestParams,
+    ) -> Result<(PaymentRequest, Option<NostrWaitInfo>), Error> {
+        // Collect available mints for the selected unit
+        let mints = self
+            .get_balances(&CurrencyUnit::from_str(&params.unit)?)
+            .await?
+            .keys()
+            .cloned()
+            .collect::<Vec<_>>();
+
+        // Transports
+        let transport_type = params.transport.to_lowercase();
+        let (transports, nostr_info): (Vec<Transport>, Option<NostrWaitInfo>) =
+            match transport_type.as_str() {
+                "nostr" => {
+                    let keys = Keys::generate();
+                    let relays = if let Some(custom_relays) = &params.nostr_relays {
+                        if !custom_relays.is_empty() {
+                            custom_relays.clone()
+                        } else {
+                            return Err(Error::Custom("No relays provided".to_string()));
+                        }
+                    } else {
+                        return Err(Error::Custom("No relays provided".to_string()));
+                    };
+
+                    // Parse relay URLs for nprofile
+                    let relay_urls = relays
+                        .iter()
+                        .map(|r| RelayUrl::parse(r))
+                        .collect::<Result<Vec<_>, _>>()
+                        .map_err(|e| Error::Custom(format!("Couldn't parse relays: {e}")))?;
+
+                    let nprofile =
+                        nostr_sdk::nips::nip19::Nip19Profile::new(keys.public_key, relay_urls);
+                    let nostr_transport = Transport {
+                        _type: TransportType::Nostr,
+                        target: nprofile.to_bech32().map_err(|e| {
+                            Error::Custom(format!("Couldn't convert nprofile to bech32: {e}"))
+                        })?,
+                        tags: Some(vec![vec!["n".to_string(), "17".to_string()]]),
+                    };
+
+                    (
+                        vec![nostr_transport],
+                        Some(NostrWaitInfo {
+                            keys,
+                            relays,
+                            pubkey: nprofile.public_key,
+                        }),
+                    )
+                }
+                "http" => {
+                    if let Some(url) = &params.http_url {
+                        let http_transport = Transport {
+                            _type: TransportType::HttpPost,
+                            target: url.clone(),
+                            tags: None,
+                        };
+                        (vec![http_transport], None)
+                    } else {
+                        // No URL provided, skip transport
+                        (vec![], None)
+                    }
+                }
+                "none" => (vec![], None),
+                _ => (vec![], None),
+            };
+
+        let nut10 = self
+            .get_pr_spending_conditions(&params)?
+            .map(Nut10SecretRequest::from);
+
+        let req = PaymentRequest {
+            payment_id: None,
+            amount: params.amount.map(Amount::from),
+            unit: Some(CurrencyUnit::from_str(&params.unit)?),
+            single_use: Some(true),
+            mints: Some(mints),
+            description: params.description,
+            transports,
+            nut10,
+        };
+
+        Ok((req, nostr_info))
+    }
+
+    /// Create a NUT-18 PaymentRequest from high-level parameters (Nostr disabled build).
+    ///
+    /// Why:
+    /// - Keep request construction consistent even when Nostr is not compiled in.
+    /// - Still advertise available mints for the unit so payers can route proofs correctly.
+    /// - Allow callers to attach an HTTP transport when out-of-band delivery is acceptable.
+    ///
+    /// Behavior notes:
+    /// - Rejects `transport == "nostr"` early so callers can surface a clear UX error.
+    /// - Encodes P2PK/multisig and HTLC constraints into a NUT-10 secret request for enforceable spending conditions.
+    ///
+    /// Returns the constructed PaymentRequest and sets `single_use = true` to discourage replay.
+    #[cfg(not(feature = "nostr"))]
+    pub async fn create_request(
+        &self,
+        params: CreateRequestParams,
+    ) -> Result<PaymentRequest, Error> {
+        // Collect available mints for the selected unit
+        let mints = self
+            .get_balances(&CurrencyUnit::from_str(&params.unit)?)
+            .await?
+            .keys()
+            .cloned()
+            .collect::<Vec<_>>();
+
+        // Transports
+        let transport_type = params.transport.to_lowercase();
+        let transports: Vec<Transport> = match transport_type.as_str() {
+            "nostr" => {
+                return Err(Error::Custom(
+                    "Nostr is not supported in this build".to_string(),
+                ))
+            }
+            "http" => {
+                if let Some(url) = &params.http_url {
+                    let http_transport = Transport {
+                        _type: TransportType::HttpPost,
+                        target: url.clone(),
+                        tags: None,
+                    };
+                    vec![http_transport]
+                } else {
+                    // No URL provided, skip transport
+                    vec![]
+                }
+            }
+            _ => vec![],
+        };
+
+        let nut10 = self
+            .get_pr_spending_conditions(&params)?
+            .map(Nut10SecretRequest::from);
+
+        let req = PaymentRequest {
+            payment_id: None,
+            amount: params.amount.map(Amount::from),
+            unit: Some(CurrencyUnit::from_str(&params.unit)?),
+            single_use: Some(true),
+            mints: Some(mints),
+            description: params.description,
+            transports,
+            nut10,
+        };
+
+        Ok(req)
+    }
+
+    /// Wait for a Nostr payment for the previously constructed PaymentRequest and receive it into the wallet.
+    #[cfg(all(feature = "nostr", not(target_arch = "wasm32")))]
+    pub async fn wait_for_nostr_payment(&self, info: NostrWaitInfo) -> Result<Amount> {
+        use futures::StreamExt;
+
+        use crate::wallet::streams::nostr::NostrPaymentEventStream;
+
+        let NostrWaitInfo {
+            keys,
+            relays,
+            pubkey,
+        } = info;
+
+        let mut stream = NostrPaymentEventStream::new(keys, relays, pubkey);
+        let cancel = stream.cancel_token();
+
+        // Optional: you may expose cancel to caller, or use a timeout here.
+        // tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(120)).await; cancel.cancel(); });
+
+        while let Some(item) = stream.next().await {
+            match item {
+                Ok(payload) => {
+                    let token = crate::nuts::Token::new(
+                        payload.mint,
+                        payload.proofs,
+                        payload.memo,
+                        payload.unit,
+                    );
+
+                    let amount = self
+                        .receive(&token.to_string(), ReceiveOptions::default())
+                        .await?;
+
+                    // Stop after first successful receipt
+                    cancel.cancel();
+                    return Ok(amount);
+                }
+                Err(_) => {
+                    // Keep listening on parse errors; if you prefer fail-fast, return the error
+                    continue;
+                }
+            }
+        }
+
+        // If stream ended without receiving a payment, return zero.
+        Ok(Amount::ZERO)
+    }
+
+    /// Wait for a Nostr payment for the previously constructed PaymentRequest and receive it into the wallet.
+    ///
+    /// wasm32 fallback: Streams are not available; we await the first matching notification and process it.
+    #[cfg(all(feature = "nostr", target_arch = "wasm32"))]
+    pub async fn wait_for_nostr_payment(&self, info: NostrWaitInfo) -> Result<Amount> {
+        use nostr_sdk::prelude::*;
+
+        let NostrWaitInfo {
+            keys,
+            relays,
+            pubkey,
+        } = info;
+
+        let client = nostr_sdk::Client::new(keys);
+
+        for r in &relays {
+            client
+                .add_read_relay(r.clone())
+                .await
+                .map_err(|e| crate::error::Error::Custom(format!("Add relay {r}: {e}")))?;
+        }
+
+        client.connect().await;
+
+        // Subscribe to events addressed to `pubkey`
+        let filter = Filter::new().pubkey(pubkey);
+        client
+            .subscribe(filter, None)
+            .await
+            .map_err(|e| crate::error::Error::Custom(format!("Subscribe: {e}")))?;
+
+        // Await notifications until we successfully parse a payment payload and receive it
+        let mut notifications = client.notifications();
+        while let Ok(notification) = notifications.recv().await {
+            if let RelayPoolNotification::Event { event, .. } = notification {
+                match client.unwrap_gift_wrap(&event).await {
+                    Ok(unwrapped) => {
+                        let rumor = unwrapped.rumor;
+                        match serde_json::from_str::<PaymentRequestPayload>(&rumor.content) {
+                            Ok(payload) => {
+                                let token = crate::nuts::Token::new(
+                                    payload.mint,
+                                    payload.proofs,
+                                    payload.memo,
+                                    payload.unit,
+                                );
+
+                                let amount = self
+                                    .receive(&token.to_string(), ReceiveOptions::default())
+                                    .await?;
+
+                                return Ok(amount);
+                            }
+                            Err(_) => {
+                                // Ignore malformed payloads and continue listening
+                                continue;
+                            }
+                        }
+                    }
+                    Err(_) => {
+                        // Ignore unwrap errors and continue listening
+                        continue;
+                    }
+                }
+            }
+        }
+
+        Ok(Amount::ZERO)
+    }
+}

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

@@ -120,3 +120,5 @@ impl Wallet {
         PaymentStream::new(self, events.into().into_subscription())
     }
 }
+#[cfg(all(feature = "nostr", not(target_arch = "wasm32")))]
+pub mod nostr;

+ 207 - 0
crates/cdk/src/wallet/streams/nostr.rs

@@ -0,0 +1,207 @@
+//! Nostr payment event stream
+//!
+//! This stream exposes incoming Nostr payment messages as a standard `Stream<Item = Result<PaymentRequestPayload, Error>>`
+//! so callers can `select!`/`next().await`, cancel via `CancellationToken`, or combine with other streams.
+
+use std::task::Poll;
+
+use cdk_common::PaymentRequestPayload;
+use futures::{FutureExt, Stream};
+use tokio::sync::mpsc;
+use tokio_util::sync::CancellationToken;
+
+use crate::error::Error;
+use crate::wallet::streams::RecvFuture;
+
+#[allow(clippy::type_complexity)]
+pub struct NostrPaymentEventStream {
+    cancel: CancellationToken,
+    // Internal channel receiver for parsed payloads
+    rx: Option<mpsc::Receiver<Result<PaymentRequestPayload, Error>>>,
+    // A future that initializes the client + subscription and spawns the notification pump
+    init_fut: Option<RecvFuture<'static, Result<(), Error>>>,
+    // Future to detect external cancellation
+    cancel_fut: Option<RecvFuture<'static, ()>>,
+    // Future awaiting the next item from `rx`
+    rx_future: Option<
+        RecvFuture<
+            'static,
+            (
+                Option<Result<PaymentRequestPayload, Error>>,
+                mpsc::Receiver<Result<PaymentRequestPayload, Error>>,
+            ),
+        >,
+    >,
+}
+
+impl NostrPaymentEventStream {
+    pub fn new(keys: nostr_sdk::Keys, relays: Vec<String>, pubkey: nostr_sdk::PublicKey) -> Self {
+        let cancel = CancellationToken::new();
+        let (tx, rx) = mpsc::channel::<Result<PaymentRequestPayload, Error>>(32);
+
+        let init_cancel = cancel.clone();
+        let init_fut = Box::pin(async move {
+            let client = nostr_sdk::Client::new(keys);
+
+            for r in &relays {
+                client
+                    .add_read_relay(r.clone())
+                    .await
+                    .map_err(|e| Error::Custom(format!("Add relay {r}: {e}")))?;
+            }
+
+            client.connect().await;
+
+            // Subscribe to events addressed to `pubkey`
+            let filter = nostr_sdk::Filter::new().pubkey(pubkey);
+            client
+                .subscribe(filter, None)
+                .await
+                .map_err(|e| Error::Custom(format!("Subscribe: {e}")))?;
+
+            let client_for_handler = client.clone();
+            // Pump notifications in a background task into the channel until cancelled
+            let _bg = tokio::spawn(async move {
+                // Use handle_notifications to avoid manually wiring broadcast receivers
+                let tx_err = tx.clone();
+                let res = client
+                    .handle_notifications(move |notification| {
+                        let tx = tx.clone();
+                        let client = client_for_handler.clone();
+                        let cancel = init_cancel.clone();
+                        async move {
+                            if cancel.is_cancelled() {
+                                return Ok(true);
+                            }
+                            if let nostr_sdk::RelayPoolNotification::Event { event, .. } =
+                                notification
+                            {
+                                match client.unwrap_gift_wrap(&event).await {
+                                    Ok(unwrapped) => {
+                                        let rumor = unwrapped.rumor;
+                                        match serde_json::from_str::<PaymentRequestPayload>(
+                                            &rumor.content,
+                                        ) {
+                                            Ok(payload) => {
+                                                // Best-effort send; if receiver closed, instruct exit
+                                                if tx.send(Ok(payload)).await.is_err() {
+                                                    return Ok(true);
+                                                }
+                                            }
+                                            Err(e) => {
+                                                let _ = tx
+                                                    .send(Err(Error::Custom(format!(
+                                                        "Invalid payload JSON: {e}"
+                                                    ))))
+                                                    .await;
+                                            }
+                                        }
+                                    }
+                                    Err(e) => {
+                                        let _ = tx
+                                            .send(Err(Error::Custom(format!(
+                                                "Unwrap gift wrap failed: {e}"
+                                            ))))
+                                            .await;
+                                    }
+                                }
+                            }
+                            Ok(false)
+                        }
+                    })
+                    .await;
+
+                if let Err(e) = res {
+                    let _ = tx_err
+                        .send(Err(Error::Custom(format!(
+                            "Notification handler error: {e}"
+                        ))))
+                        .await;
+                }
+            });
+
+            Ok(())
+        });
+
+        Self {
+            cancel,
+            rx: Some(rx),
+            init_fut: Some(init_fut),
+            cancel_fut: None,
+            rx_future: None,
+        }
+    }
+
+    pub fn cancel_token(&self) -> CancellationToken {
+        self.cancel.clone()
+    }
+}
+
+impl Stream for NostrPaymentEventStream {
+    type Item = Result<PaymentRequestPayload, Error>;
+
+    fn poll_next(
+        self: std::pin::Pin<&mut Self>,
+        cx: &mut std::task::Context<'_>,
+    ) -> Poll<Option<Self::Item>> {
+        let this = self.get_mut();
+
+        // Check external cancellation
+        if this.cancel_fut.is_none() {
+            let cancel = this.cancel.clone();
+            this.cancel_fut = Some(Box::pin(async move { cancel.cancelled().await }));
+        }
+        if let Some(mut fut) = this.cancel_fut.take() {
+            if fut.poll_unpin(cx).is_ready() {
+                // Drop receiver to end the stream
+                this.rx.take();
+                return Poll::Ready(None);
+            }
+            this.cancel_fut = Some(fut);
+        }
+
+        // Drive initialization
+        if let Some(mut init) = this.init_fut.take() {
+            match init.poll_unpin(cx) {
+                Poll::Pending => {
+                    this.init_fut = Some(init);
+                    return Poll::Pending;
+                }
+                Poll::Ready(Err(e)) => {
+                    return Poll::Ready(Some(Err(e)));
+                }
+                Poll::Ready(Ok(())) => {
+                    // fallthrough
+                }
+            }
+        }
+
+        // Drive next item from the internal channel
+        if this.rx.is_none() {
+            return Poll::Ready(None);
+        }
+
+        if this.rx_future.is_none() {
+            let mut rx = this.rx.take().expect("receiver");
+            this.rx_future = Some(Box::pin(async move {
+                let item = rx.recv().await;
+                (item, rx)
+            }));
+        }
+
+        let mut fut = this.rx_future.take().ok_or(Error::Internal)?;
+        match fut.poll_unpin(cx) {
+            Poll::Pending => {
+                this.rx_future = Some(fut);
+                Poll::Pending
+            }
+            Poll::Ready((item, rx)) => {
+                this.rx = Some(rx);
+                match item {
+                    None => Poll::Ready(None),
+                    Some(item) => Poll::Ready(Some(item)),
+                }
+            }
+        }
+    }
+}