mirror of
https://github.com/aljazceru/cdk.git
synced 2026-02-18 19:45:45 +01:00
chore: move pay_request logic into cdk lib (#1028)
* pay request into cdk lib
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
crates/cdk/src/wallet/payment_request.rs
Normal file
626
crates/cdk/src/wallet/payment_request.rs
Normal 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) = ¶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<NostrWaitInfo>), Error> {
|
||||
// Collect available mints for the selected unit
|
||||
let mints = self
|
||||
.get_balances(&CurrencyUnit::from_str(¶ms.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) = ¶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::<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) = ¶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<PaymentRequest, Error> {
|
||||
// Collect available mints for the selected unit
|
||||
let mints = self
|
||||
.get_balances(&CurrencyUnit::from_str(¶ms.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) = ¶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<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)
|
||||
}
|
||||
}
|
||||
@@ -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
crates/cdk/src/wallet/streams/nostr.rs
Normal file
207
crates/cdk/src/wallet/streams/nostr.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user