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

* pay request into cdk lib
This commit is contained in:
lollerfirst
2025-09-15 20:50:08 +02:00
committed by GitHub
parent 7d78240da5
commit 4f65441c0d
9 changed files with 880 additions and 378 deletions

View File

@@ -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

View File

@@ -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 {
// 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(())

View File

@@ -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()))
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)
}
}

View File

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

View File

@@ -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)),
}
}
}
}
}