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