diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index a2c2e2dd..92eba990 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/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 diff --git a/crates/cdk-cli/src/sub_commands/create_request.rs b/crates/cdk-cli/src/sub_commands/create_request.rs index d92119e6..66b08534 100644 --- a/crates/cdk-cli/src/sub_commands/create_request.rs +++ b/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 = 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 { + // Print the request to stdout + println!("{}", req); + + // 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 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?; + let amount = multi_mint_wallet.wait_for_nostr_payment(info).await?; + println!("Received {}", amount); } Ok(()) diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs index 6fd088a4..a5e82006 100644 --- a/crates/cdk-cli/src/sub_commands/pay_request.rs +++ b/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::>() - .join(", ") - ); - - if !gift_wrap.failed.is_empty() { - println!( - "Could not publish to {:?}", - gift_wrap - .failed - .keys() - .map(|relay| relay.to_string()) - .collect::>() - .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())) } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 17ae3e76..3c08e3c3 100644 --- a/crates/cdk/Cargo.toml +++ b/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 diff --git a/crates/cdk/src/lib.rs b/crates/cdk/src/lib.rs index babe1ecb..e9938fe8 100644 --- a/crates/cdk/src/lib.rs +++ b/crates/cdk/src/lib.rs @@ -68,3 +68,6 @@ pub type Result> = std::result::Result; /// 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; diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 3209be7d..8a80f92b 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/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; diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs new file mode 100644 index 00000000..f042872d --- /dev/null +++ b/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, + ) -> 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::>() + .join(", ") + ); + + if !gift_wrap.failed.is_empty() { + println!( + "Could not publish to {}", + gift_wrap + .failed + .keys() + .map(|relay| relay.to_string()) + .collect::>() + .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, + /// Currency unit string (e.g., "sat") + pub unit: String, + /// Optional human-readable description for the request + pub description: Option, + /// Optional set of public keys for P2PK spending conditions (multisig supported) + pub pubkeys: Option>, // 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, // HTLC hash + /// Optional HTLC preimage (mutually exclusive with `hash`) + pub preimage: Option, // 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, // when transport == http + /// List of Nostr relay URLs to include in the nprofile (used if `transport == nostr`) + pub nostr_relays: Option>, // 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, + /// 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, Error> { + // Spending conditions + let spending_conditions: Option = + if let Some(pubkey_strings) = ¶ms.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) = ¶ms.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) = ¶ms.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) = ¶ms.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) = ¶ms.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), Error> { + // Collect available mints for the selected unit + let mints = self + .get_balances(&CurrencyUnit::from_str(¶ms.unit)?) + .await? + .keys() + .cloned() + .collect::>(); + + // Transports + let transport_type = params.transport.to_lowercase(); + let (transports, nostr_info): (Vec, Option) = + match transport_type.as_str() { + "nostr" => { + let keys = Keys::generate(); + let relays = if let Some(custom_relays) = ¶ms.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::, _>>() + .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) = ¶ms.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(¶ms)? + .map(Nut10SecretRequest::from); + + let req = PaymentRequest { + payment_id: None, + amount: params.amount.map(Amount::from), + unit: Some(CurrencyUnit::from_str(¶ms.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 { + // Collect available mints for the selected unit + let mints = self + .get_balances(&CurrencyUnit::from_str(¶ms.unit)?) + .await? + .keys() + .cloned() + .collect::>(); + + // Transports + let transport_type = params.transport.to_lowercase(); + let transports: Vec = match transport_type.as_str() { + "nostr" => { + return Err(Error::Custom( + "Nostr is not supported in this build".to_string(), + )) + } + "http" => { + if let Some(url) = ¶ms.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(¶ms)? + .map(Nut10SecretRequest::from); + + let req = PaymentRequest { + payment_id: None, + amount: params.amount.map(Amount::from), + unit: Some(CurrencyUnit::from_str(¶ms.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 { + 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 { + 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::(&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) + } +} diff --git a/crates/cdk/src/wallet/streams/mod.rs b/crates/cdk/src/wallet/streams/mod.rs index fda0945e..fb7cf80b 100644 --- a/crates/cdk/src/wallet/streams/mod.rs +++ b/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; diff --git a/crates/cdk/src/wallet/streams/nostr.rs b/crates/cdk/src/wallet/streams/nostr.rs new file mode 100644 index 00000000..45dc7d8d --- /dev/null +++ b/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>` +//! 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>>, + // A future that initializes the client + subscription and spawns the notification pump + init_fut: Option>>, + // Future to detect external cancellation + cancel_fut: Option>, + // Future awaiting the next item from `rx` + rx_future: Option< + RecvFuture< + 'static, + ( + Option>, + mpsc::Receiver>, + ), + >, + >, +} + +impl NostrPaymentEventStream { + pub fn new(keys: nostr_sdk::Keys, relays: Vec, pubkey: nostr_sdk::PublicKey) -> Self { + let cancel = CancellationToken::new(); + let (tx, rx) = mpsc::channel::>(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::( + &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; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + 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)), + } + } + } + } +}