Bläddra i källkod

feat(cdk-cli): decode, send, receice payment request

thesimplekid 4 månader sedan
förälder
incheckning
c4abafb617

+ 9 - 2
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"

+ 15 - 0
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
+        }
     }
 }

+ 104 - 0
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<u64>,
+    /// Currency unit e.g. sat
+    #[arg(default_value = "sat")]
+    unit: String,
+    /// Quote description
+    description: Option<String>,
+}
+
+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<cdk::mint_url::MintUrl> = 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);
+
+    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(())
+}

+ 19 - 0
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(())
+}

+ 3 - 0
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;

+ 177 - 0
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::<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");
+            }
+        }
+    }
+
+    Ok(())
+}

+ 11 - 6
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<String> = 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());
                 }

+ 1 - 0
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};

+ 48 - 6
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<Self, Self::Err> {
+        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<String>,
+    /// Memo
+    pub memo: Option<String>,
+    /// 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()),