diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index bd3b26b0f..45f0b1133 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -25,8 +25,15 @@ tracing = { version = "0.1", default-features = false, features = ["attributes", tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } rand = "0.8.5" home = "0.5.5" -nostr-sdk = { version = "0.33.0", default-features = false, features = [ +nostr-sdk = { version = "0.35.0", default-features = false, features = [ "nip04", - "nip44" + "nip44", + "nip59" +]} +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", + "rustls-tls-native-roots", + "socks", ]} url = "2.3" diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 0de66bc51..ac984dd56 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -72,6 +72,12 @@ enum Commands { UpdateMintUrl(sub_commands::update_mint_url::UpdateMintUrlSubCommand), /// Get proofs from mint. ListMintProofs, + /// Decode a payment request + DecodeRequest(sub_commands::decode_request::DecodePaymentRequestSubCommand), + /// Pay a payment request + PayRequest(sub_commands::pay_request::PayRequestSubCommand), + /// Create Payment request + CreateRequest(sub_commands::create_request::CreateRequestSubCommand), } #[tokio::main] @@ -204,5 +210,14 @@ async fn main() -> Result<()> { Commands::ListMintProofs => { sub_commands::list_mint_proofs::proofs(&multi_mint_wallet).await } + Commands::DecodeRequest(sub_command_args) => { + sub_commands::decode_request::decode_payment_request(sub_command_args) + } + Commands::PayRequest(sub_command_args) => { + sub_commands::pay_request::pay_request(&multi_mint_wallet, sub_command_args).await + } + Commands::CreateRequest(sub_command_args) => { + sub_commands::create_request::create_request(&multi_mint_wallet, sub_command_args).await + } } } diff --git a/crates/cdk-cli/src/sub_commands/create_request.rs b/crates/cdk-cli/src/sub_commands/create_request.rs new file mode 100644 index 000000000..ece21d113 --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/create_request.rs @@ -0,0 +1,104 @@ +use anyhow::Result; +use cdk::{ + nuts::{ + nut18::TransportType, CurrencyUnit, PaymentRequest, PaymentRequestPayload, Token, Transport, + }, + wallet::MultiMintWallet, +}; +use clap::Args; +use nostr_sdk::prelude::*; +use nostr_sdk::{nips::nip19::Nip19Profile, Client as NostrClient, Filter, Keys, ToBech32}; + +#[derive(Args)] +pub struct CreateRequestSubCommand { + #[arg(short, long)] + amount: Option, + /// Currency unit e.g. sat + #[arg(default_value = "sat")] + unit: String, + /// Quote description + description: Option, +} + +pub async fn create_request( + multi_mint_wallet: &MultiMintWallet, + sub_command_args: &CreateRequestSubCommand, +) -> Result<()> { + let keys = Keys::generate(); + let relays = vec!["wss://relay.nos.social", "wss://relay.damus.io"]; + + 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()]]), + }; + + let mints: Vec = multi_mint_wallet + .get_balances(&CurrencyUnit::Sat) + .await? + .keys() + .cloned() + .collect(); + + let req = PaymentRequest { + payment_id: None, + amount: sub_command_args.amount.map(|a| a.into()), + unit: None, + single_use: Some(true), + mints: Some(mints), + description: sub_command_args.description.clone(), + transports: vec![nostr_transport], + }; + + println!("{}", req.to_string()); + + let client = NostrClient::new(keys); + + let filter = Filter::new().pubkey(nprofile.public_key); + + for relay in relays { + client.add_read_relay(relay).await?; + } + + client.connect().await; + + client.subscribe(vec![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, + Some(payload.unit), + ); + + let amount = multi_mint_wallet + .receive(&token.to_string(), &[], &[]) + .await?; + + println!("Received {}", amount); + exit = true; + } + Ok(exit) // Set to true to exit from the loop + }) + .await?; + + Ok(()) +} diff --git a/crates/cdk-cli/src/sub_commands/decode_request.rs b/crates/cdk-cli/src/sub_commands/decode_request.rs new file mode 100644 index 000000000..3a0f1c88f --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/decode_request.rs @@ -0,0 +1,19 @@ +use std::str::FromStr; + +use anyhow::Result; +use cdk::nuts::PaymentRequest; +use cdk::util::serialize_to_cbor_diag; +use clap::Args; + +#[derive(Args)] +pub struct DecodePaymentRequestSubCommand { + /// Payment request + payment_request: String, +} + +pub fn decode_payment_request(sub_command_args: &DecodePaymentRequestSubCommand) -> Result<()> { + let payment_request = PaymentRequest::from_str(&sub_command_args.payment_request)?; + + println!("{:}", serialize_to_cbor_diag(&payment_request)?); + Ok(()) +} diff --git a/crates/cdk-cli/src/sub_commands/mod.rs b/crates/cdk-cli/src/sub_commands/mod.rs index eee3cf327..8256d0aea 100644 --- a/crates/cdk-cli/src/sub_commands/mod.rs +++ b/crates/cdk-cli/src/sub_commands/mod.rs @@ -1,11 +1,14 @@ pub mod balance; pub mod burn; pub mod check_spent; +pub mod create_request; +pub mod decode_request; pub mod decode_token; pub mod list_mint_proofs; pub mod melt; pub mod mint; pub mod mint_info; +pub mod pay_request; pub mod pending_mints; pub mod receive; pub mod restore; diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs new file mode 100644 index 000000000..91cd658cc --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/pay_request.rs @@ -0,0 +1,177 @@ +use std::io::{self, Write}; + +use anyhow::{anyhow, Result}; +use cdk::{ + amount::SplitTarget, + nuts::{nut18::TransportType, PaymentRequest, PaymentRequestPayload}, + wallet::{MultiMintWallet, SendKind}, +}; +use clap::Args; +use nostr_sdk::{nips::nip19::Nip19Profile, Client as NostrClient, EventBuilder, FromBech32, Keys}; +use reqwest::Client; + +#[derive(Args)] +pub struct PayRequestSubCommand { + payment_request: PaymentRequest, +} + +pub async fn pay_request( + multi_mint_wallet: &MultiMintWallet, + sub_command_args: &PayRequestSubCommand, +) -> Result<()> { + let payment_request = &sub_command_args.payment_request; + + let unit = payment_request.unit; + + let amount = match payment_request.amount { + Some(amount) => amount, + None => { + println!("Enter the amount you would like to pay"); + + let mut user_input = String::new(); + let stdin = io::stdin(); + io::stdout().flush().unwrap(); + stdin.read_line(&mut user_input)?; + + let amount: u64 = user_input.trim().parse()?; + + amount.into() + } + }; + + let request_mints = &payment_request.mints; + + let wallet_mints = multi_mint_wallet.get_wallets().await; + + // Wallets where unit, balance and mint match request + let mut matching_wallets = vec![]; + + for wallet in wallet_mints.iter() { + let balance = wallet.total_balance().await?; + + if let Some(request_mints) = request_mints { + if !request_mints.contains(&wallet.mint_url) { + continue; + } + } + + if let Some(unit) = unit { + if wallet.unit != unit { + continue; + } + } + + if balance >= amount { + matching_wallets.push(wallet); + } + } + + let matching_wallet = matching_wallets.first().unwrap(); + + // We prefer nostr transport if it is available to hide ip. + let transport = payment_request + .transports + .iter() + .find(|t| t._type == TransportType::Nostr) + .or_else(|| { + payment_request + .transports + .iter() + .find(|t| t._type == TransportType::HttpPost) + }) + .ok_or(anyhow!("No supported transport method found"))?; + + let proofs = matching_wallet + .send( + amount, + None, + None, + &SplitTarget::default(), + &SendKind::default(), + true, + ) + .await? + .proofs() + .get(&matching_wallet.mint_url) + .unwrap() + .clone(); + + let payload = PaymentRequestPayload { + id: payment_request.payment_id.clone(), + memo: None, + mint: matching_wallet.mint_url.clone(), + unit: matching_wallet.unit, + 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)?, + [], + ); + + 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 + .iter() + .map(|(relay, _s)| 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"); + } + } + } + + Ok(()) +} diff --git a/crates/cdk-cli/src/sub_commands/receive.rs b/crates/cdk-cli/src/sub_commands/receive.rs index 24eea39b5..f7e621cb5 100644 --- a/crates/cdk-cli/src/sub_commands/receive.rs +++ b/crates/cdk-cli/src/sub_commands/receive.rs @@ -184,20 +184,25 @@ async fn nostr_receive( let client = nostr_sdk::Client::default(); - client.add_relays(relays).await?; - client.connect().await; - let events = client.get_events_of(vec![filter], None).await?; + let events = client + .get_events_of( + vec![filter], + nostr_sdk::EventSource::Relays { + timeout: None, + specific_relays: Some(relays), + }, + ) + .await?; let mut tokens: HashSet = HashSet::new(); let keys = Keys::from_str(&(nostr_signing_key).to_secret_hex())?; for event in events { - if event.kind() == Kind::EncryptedDirectMessage { - if let Ok(msg) = nip04::decrypt(keys.secret_key()?, event.author_ref(), event.content()) - { + if event.kind == Kind::EncryptedDirectMessage { + if let Ok(msg) = nip04::decrypt(keys.secret_key(), &event.pubkey, event.content) { if let Some(token) = cdk::wallet::util::token_from_text(&msg) { tokens.insert(token.to_string()); } diff --git a/crates/cdk/src/nuts/mod.rs b/crates/cdk/src/nuts/mod.rs index 12d317df9..07518bff1 100644 --- a/crates/cdk/src/nuts/mod.rs +++ b/crates/cdk/src/nuts/mod.rs @@ -47,3 +47,4 @@ pub use nut11::{Conditions, P2PKWitness, SigFlag, SpendingConditions}; pub use nut12::{BlindSignatureDleq, ProofDleq}; pub use nut14::HTLCWitness; pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings}; +pub use nut18::{PaymentRequest, PaymentRequestPayload, Transport}; diff --git a/crates/cdk/src/nuts/nut18.rs b/crates/cdk/src/nuts/nut18.rs index 2c574d153..d165b1484 100644 --- a/crates/cdk/src/nuts/nut18.rs +++ b/crates/cdk/src/nuts/nut18.rs @@ -14,7 +14,7 @@ use thiserror::Error; use crate::{mint_url::MintUrl, Amount}; -use super::CurrencyUnit; +use super::{CurrencyUnit, Proofs}; const PAYMENT_REQUEST_PREFIX: &str = "creqA"; @@ -32,12 +32,39 @@ pub enum Error { Base64Error(#[from] bitcoin::base64::DecodeError), } +/// Transport Type +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportType { + /// Nostr + #[serde(rename = "nostr")] + Nostr, + /// Http post + #[serde(rename = "post")] + HttpPost, +} + +impl fmt::Display for TransportType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use serde::ser::Error; + let t = serde_json::to_string(self).map_err(|e| fmt::Error::custom(e.to_string()))?; + write!(f, "{}", t) + } +} + +impl FromStr for Transport { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} + /// Transport -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct Transport { /// Type #[serde(rename = "t")] - pub _type: String, + pub _type: TransportType, /// Target #[serde(rename = "a")] pub target: String, @@ -47,7 +74,7 @@ pub struct Transport { } /// Payment Request -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentRequest { /// `Payment id` #[serde(rename = "i")] @@ -98,6 +125,21 @@ impl FromStr for PaymentRequest { } } +/// Payment Request +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentRequestPayload { + /// Id + pub id: Option, + /// Memo + pub memo: Option, + /// Mint + pub mint: MintUrl, + /// Unit + pub unit: CurrencyUnit, + /// Proofs + pub proofs: Proofs, +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -121,7 +163,7 @@ mod tests { let transport = req.transports.first().unwrap(); - let expected_transport = Transport {_type: "nostr".to_string(), target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])}; + let expected_transport = Transport {_type: TransportType::Nostr, target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])}; assert_eq!(transport, &expected_transport); @@ -130,7 +172,7 @@ mod tests { #[test] fn test_roundtrip_payment_req() -> anyhow::Result<()> { - let transport = Transport {_type: "nostr".to_string(), target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])}; + let transport = Transport {_type: TransportType::Nostr, target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])}; let request = PaymentRequest { payment_id: Some("b7a90176".to_string()),