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

This commit is contained in:
thesimplekid
2024-10-22 14:04:42 +01:00
parent 3cd5f463d7
commit c4abafb617
9 changed files with 387 additions and 14 deletions

View File

@@ -25,8 +25,15 @@ tracing = { version = "0.1", default-features = false, features = ["attributes",
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
rand = "0.8.5" rand = "0.8.5"
home = "0.5.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", "nip04",
"nip44" "nip44",
"nip59"
]}
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
"rustls-tls-native-roots",
"socks",
]} ]}
url = "2.3" url = "2.3"

View File

@@ -72,6 +72,12 @@ enum Commands {
UpdateMintUrl(sub_commands::update_mint_url::UpdateMintUrlSubCommand), UpdateMintUrl(sub_commands::update_mint_url::UpdateMintUrlSubCommand),
/// Get proofs from mint. /// Get proofs from mint.
ListMintProofs, 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] #[tokio::main]
@@ -204,5 +210,14 @@ async fn main() -> Result<()> {
Commands::ListMintProofs => { Commands::ListMintProofs => {
sub_commands::list_mint_proofs::proofs(&multi_mint_wallet).await 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
}
} }
} }

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
pub mod balance; pub mod balance;
pub mod burn; pub mod burn;
pub mod check_spent; pub mod check_spent;
pub mod create_request;
pub mod decode_request;
pub mod decode_token; pub mod decode_token;
pub mod list_mint_proofs; pub mod list_mint_proofs;
pub mod melt; pub mod melt;
pub mod mint; pub mod mint;
pub mod mint_info; pub mod mint_info;
pub mod pay_request;
pub mod pending_mints; pub mod pending_mints;
pub mod receive; pub mod receive;
pub mod restore; pub mod restore;

View File

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

View File

@@ -184,20 +184,25 @@ async fn nostr_receive(
let client = nostr_sdk::Client::default(); let client = nostr_sdk::Client::default();
client.add_relays(relays).await?;
client.connect().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 mut tokens: HashSet<String> = HashSet::new();
let keys = Keys::from_str(&(nostr_signing_key).to_secret_hex())?; let keys = Keys::from_str(&(nostr_signing_key).to_secret_hex())?;
for event in events { for event in events {
if event.kind() == Kind::EncryptedDirectMessage { if event.kind == Kind::EncryptedDirectMessage {
if let Ok(msg) = nip04::decrypt(keys.secret_key()?, event.author_ref(), event.content()) if let Ok(msg) = nip04::decrypt(keys.secret_key(), &event.pubkey, event.content) {
{
if let Some(token) = cdk::wallet::util::token_from_text(&msg) { if let Some(token) = cdk::wallet::util::token_from_text(&msg) {
tokens.insert(token.to_string()); tokens.insert(token.to_string());
} }

View File

@@ -47,3 +47,4 @@ pub use nut11::{Conditions, P2PKWitness, SigFlag, SpendingConditions};
pub use nut12::{BlindSignatureDleq, ProofDleq}; pub use nut12::{BlindSignatureDleq, ProofDleq};
pub use nut14::HTLCWitness; pub use nut14::HTLCWitness;
pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings}; pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings};
pub use nut18::{PaymentRequest, PaymentRequestPayload, Transport};

View File

@@ -14,7 +14,7 @@ use thiserror::Error;
use crate::{mint_url::MintUrl, Amount}; use crate::{mint_url::MintUrl, Amount};
use super::CurrencyUnit; use super::{CurrencyUnit, Proofs};
const PAYMENT_REQUEST_PREFIX: &str = "creqA"; const PAYMENT_REQUEST_PREFIX: &str = "creqA";
@@ -32,12 +32,39 @@ pub enum Error {
Base64Error(#[from] bitcoin::base64::DecodeError), 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 /// Transport
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct Transport { pub struct Transport {
/// Type /// Type
#[serde(rename = "t")] #[serde(rename = "t")]
pub _type: String, pub _type: TransportType,
/// Target /// Target
#[serde(rename = "a")] #[serde(rename = "a")]
pub target: String, pub target: String,
@@ -47,7 +74,7 @@ pub struct Transport {
} }
/// Payment Request /// Payment Request
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentRequest { pub struct PaymentRequest {
/// `Payment id` /// `Payment id`
#[serde(rename = "i")] #[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)] #[cfg(test)]
mod tests { mod tests {
use std::str::FromStr; use std::str::FromStr;
@@ -121,7 +163,7 @@ mod tests {
let transport = req.transports.first().unwrap(); 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); assert_eq!(transport, &expected_transport);
@@ -130,7 +172,7 @@ mod tests {
#[test] #[test]
fn test_roundtrip_payment_req() -> anyhow::Result<()> { 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 { let request = PaymentRequest {
payment_id: Some("b7a90176".to_string()), payment_id: Some("b7a90176".to_string()),