diff --git a/Cargo.lock b/Cargo.lock index 5e615ea..2c04fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,7 @@ dependencies = [ "bdk_kyoto", "bdk_redb", "bdk_wallet", + "bitcoin-payment-instructions", "clap", "cli-table", "dirs", @@ -280,7 +281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f549541116c9f100cd7aa06b5e551e49bcc1f8dda1d0583e014de891aa943329" dependencies = [ "bitcoin", - "hashbrown", + "hashbrown 0.14.5", "serde", ] @@ -525,6 +526,19 @@ dependencies = [ "toml", ] +[[package]] +name = "bitcoin-payment-instructions" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd1e2069af33b2796a0d81097ffbe42eb0e85f9d9890bfc022f6960433f7399" +dependencies = [ + "bitcoin", + "dnssec-prover", + "getrandom 0.3.3", + "lightning", + "lightning-invoice", +] + [[package]] name = "bitcoin-units" version = "0.1.2" @@ -1008,6 +1022,16 @@ dependencies = [ "syn", ] +[[package]] +name = "dnssec-prover" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf" +dependencies = [ + "bitcoin_hashes 0.14.0", + "tokio", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1297,6 +1321,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1313,7 +1343,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -1769,6 +1799,12 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libredox" version = "0.1.9" @@ -1790,6 +1826,42 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lightning" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe63f56b10d214be1ade8698ee80af82d981237cae3e581e7a631f5f4959f3" +dependencies = [ + "bech32", + "bitcoin", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice", + "lightning-types", + "possiblyrandom", +] + +[[package]] +name = "lightning-invoice" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11209f386879b97198b2bfc9e9c1e5d42870825c6bd4376f17f95357244d6600" +dependencies = [ + "bech32", + "bitcoin", + "lightning-types", +] + +[[package]] +name = "lightning-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cd84d4e71472035903e43caded8ecc123066ce466329ccd5ae537a8d5488c7" +dependencies = [ + "bitcoin", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2122,6 +2194,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "possiblyrandom" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b122a615d72104fb3d8b26523fdf9232cd8ee06949fb37e4ce3ff964d15dffd" +dependencies = [ + "getrandom 0.2.16", +] + [[package]] name = "potential_utf" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 366ee5b..277007a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ shlex = { version = "1.3.0", optional = true } payjoin = { version = "1.0.0-rc.1", features = ["v1", "v2", "io", "_test-utils"], optional = true} reqwest = { version = "0.12.23", default-features = false, optional = true } url = { version = "2.5.4", optional = true } +bitcoin-payment-instructions = { version = "0.5.0", optional = true} [features] default = ["repl", "sqlite"] @@ -49,7 +50,8 @@ redb = ["bdk_redb"] cbf = ["bdk_kyoto", "_payjoin-dependencies"] electrum = ["bdk_electrum", "_payjoin-dependencies"] esplora = ["bdk_esplora", "_payjoin-dependencies"] -rpc = ["bdk_bitcoind_rpc", "_payjoin-dependencies"] +rpc = ["bdk_bitcoind_rpc", "_payjoin-dependencies"] +dns_payment = ["bitcoin-payment-instructions"] # Internal features _payjoin-dependencies = ["payjoin", "reqwest", "url"] diff --git a/src/commands.rs b/src/commands.rs index 7e65af2..fa1999c 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -277,6 +277,13 @@ pub enum OfflineWalletSubCommand { Transactions, /// Returns the current wallet balance. Balance, + /// Resolves the given DNS payment instructions + ResolveDnsRecipient { + /// Human Readable Name to resolve + hrn: String, + /// The amount you're willing to send to the HRN + amount: u64, + }, /// Creates a new unsigned transaction. CreateTx { /// Adds a recipient to the transaction. @@ -284,6 +291,9 @@ pub enum OfflineWalletSubCommand { // Address and amount parsing is done at run time in handler function. #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] recipients: Vec<(ScriptBuf, u64)>, + /// Adds DNS recipients to the transaction + #[arg(long = "to_dns")] + dns_recipients: Option, /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. #[arg(long = "send_all", short = 'a')] send_all: bool, diff --git a/src/dns_payment_instructions.rs b/src/dns_payment_instructions.rs new file mode 100644 index 0000000..3acd633 --- /dev/null +++ b/src/dns_payment_instructions.rs @@ -0,0 +1,107 @@ +use bdk_wallet::bitcoin::{Address, Amount, Network}; +use bitcoin_payment_instructions::{ + FixedAmountPaymentInstructions, ParseError, PaymentInstructions, PaymentMethod, + PaymentMethodType, amount, dns_resolver::DNSHrnResolver, hrn_resolution::HrnResolver, +}; +use core::{net::SocketAddr, str::FromStr}; + +async fn parse_dns_instructions( + hrn: &str, + resolver: &impl HrnResolver, + network: Network, +) -> Result { + let instructions = PaymentInstructions::parse(hrn, network, resolver, true).await?; + Ok(instructions) +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct Payment { + pub address: Address, + pub amount: Amount, + pub min_amount: Option, + pub max_amount: Option, + pub dnssec_proof: Option>, +} + +fn process_fixed_instructions( + instructions: &FixedAmountPaymentInstructions, +) -> Result { + // Look for on chain payment method as it's the only one we can support + let PaymentMethod::OnChain(addr) = instructions + .methods() + .iter() + .find(|ix| matches!(ix, PaymentMethod::OnChain(_))) + .map(|pm| pm) + .unwrap() + else { + return Err(ParseError::InvalidInstructions( + "Unsupported payment method", + )); + }; + + let Some(onchain_amount) = instructions.onchain_payment_amount() else { + return Err(ParseError::InvalidInstructions( + "On chain amount should be specified", + )); + }; + + // We need this conversion since Amount from instructions is different from Amount from bitcoin + let onchain_amount = Amount::from_sat(onchain_amount.milli_sats()); + + Ok(Payment { + address: addr.clone(), + amount: onchain_amount, + min_amount: None, + max_amount: None, + dnssec_proof: instructions.bip_353_dnssec_proof().clone(), + }) +} + +pub async fn resolve_dns_recipient( + hrn: &str, + amount: Amount, + network: Network, +) -> Result { + let resolver = DNSHrnResolver(SocketAddr::from_str("8.8.8.8:53").expect("Should not fail.")); + let payment_instructions = parse_dns_instructions(hrn, &resolver, network).await?; + + match payment_instructions { + PaymentInstructions::ConfigurableAmount(instructions) => { + // Look for on chain payment method as it's the only one we can support + if instructions + .methods() + .find(|method| matches!(method.method_type(), PaymentMethodType::OnChain)) + .is_none() + { + return Err(ParseError::InvalidInstructions( + "Unsupported payment method", + )); + } + + let min_amount = instructions + .min_amt() + .map(|amnt| Amount::from_sat(amnt.sats_rounding_up())); + let max_amount = instructions + .max_amt() + .map(|amnt| Amount::from_sat(amnt.sats_rounding_up())); + + let fixed_instructions = instructions + .set_amount( + amount::Amount::from_sats(amount.to_sat()).unwrap(), + &resolver, + ) + .await + .map_err(|s| ParseError::InvalidInstructions(s))?; + + let mut instructions = process_fixed_instructions(&fixed_instructions)?; + + instructions.min_amount = min_amount; + instructions.max_amount = max_amount; + + Ok(instructions) + } + + PaymentInstructions::FixedAmount(instructions) => process_fixed_instructions(&instructions), + } +} diff --git a/src/handlers.rs b/src/handlers.rs index 5439bc0..3fc49a1 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -11,6 +11,7 @@ //! This module describes all the command handling logic used by bdk-cli. use crate::commands::OfflineWalletSubCommand::*; use crate::commands::*; +use crate::dns_payment_instructions::resolve_dns_recipient; use crate::error::BDKCliError as Error; #[cfg(any(feature = "sqlite", feature = "redb"))] use crate::persister::Persister; @@ -18,6 +19,7 @@ use crate::utils::*; #[cfg(feature = "redb")] use bdk_redb::Store as RedbStore; use bdk_wallet::bip39::{Language, Mnemonic}; +use bdk_wallet::bitcoin::ScriptBuf; use bdk_wallet::bitcoin::base64::Engine; use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; use bdk_wallet::bitcoin::{ @@ -94,7 +96,7 @@ const NUMS_UNSPENDABLE_KEY_HEX: &str = /// Execute an offline wallet sub-command /// /// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`]. -pub fn handle_offline_wallet_subcommand( +pub async fn handle_offline_wallet_subcommand( wallet: &mut Wallet, wallet_opts: &WalletOpts, cli_opts: &CliOpts, @@ -328,8 +330,22 @@ pub fn handle_offline_wallet_subcommand( } } + ResolveDnsRecipient { hrn, amount } => { + let resolved = resolve_dns_recipient(&hrn, Amount::from_sat(amount), wallet.network()) + .await + .map_err(|e| Error::Generic(format!("{:?}", e)))?; + + Ok(serde_json::to_string_pretty(&json!({ + "hrn": hrn, + "recipient": resolved.address, + "min_amount": resolved.min_amount.unwrap_or_default(), + "max_amount": resolved.max_amount.unwrap_or_default(), + }))?) + } + CreateTx { recipients, + dns_recipients, send_all, enable_rbf, offline_signer, @@ -346,10 +362,34 @@ pub fn handle_offline_wallet_subcommand( if send_all { tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); } else { - let recipients = recipients + let mut recipients: Vec<(ScriptBuf, Amount)> = recipients .into_iter() .map(|(script, amount)| (script, Amount::from_sat(amount))) .collect(); + + if let Some(recip) = dns_recipients { + let parsed_recip = parse_dns_recipients(&recip) + .await + .map_err(|pe| Error::Generic(format!("Resolution failed: {pe}")))?; + + // Validates if the amount the user wants to send is in the range of what the payment instructions returned + parsed_recip.iter().try_for_each(|(_, r)| { + let amount = r.amount.to_sat(); + if r.min_amount.map_or(false, |min| amount < min.to_sat()) { + return Err(Error::Generic("Amount lesser than min".to_string())); + } + if r.max_amount.map_or(false, |max| amount > max.to_sat()) { + return Err(Error::Generic("Amount greater than max".to_string())); + } + Ok(()) + })?; + + let mut vec_recip = parsed_recip + .iter() + .map(|recip| (recip.1.address.script_pubkey(), recip.1.amount)) + .collect::>(); + recipients.append(&mut vec_recip); + } tx_builder.set_recipients(recipients); } @@ -1046,7 +1086,8 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { wallet_opts, &cli_opts, offline_subcommand.clone(), - )?; + ) + .await?; wallet.persist(&mut persister)?; result }; @@ -1194,6 +1235,7 @@ async fn respond( } => { let value = handle_offline_wallet_subcommand(wallet, wallet_opts, cli_opts, offline_subcommand) + .await .map_err(|e| e.to_string())?; Some(value) } diff --git a/src/main.rs b/src/main.rs index 81190bf..64e3466 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,9 @@ mod payjoin; mod persister; mod utils; +#[cfg(feature = "dns_payment")] +mod dns_payment_instructions; + use bdk_wallet::bitcoin::Network; use log::{debug, error, warn}; diff --git a/src/utils.rs b/src/utils.rs index 8a3ee04..8ff169a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,7 +9,10 @@ //! Utility Tools //! //! This module includes all the utility tools used by the App. -use crate::error::BDKCliError as Error; +use crate::{ + dns_payment_instructions::{Payment, resolve_dns_recipient}, + error::BDKCliError as Error, +}; use std::{ fmt::Display, path::{Path, PathBuf}, @@ -25,7 +28,10 @@ use bdk_kyoto::{ }; use bdk_wallet::{ KeychainKind, - bitcoin::bip32::{DerivationPath, Xpub}, + bitcoin::{ + Amount, + bip32::{DerivationPath, Xpub}, + }, keys::DescriptorPublicKey, miniscript::{ Descriptor, Miniscript, Terminal, @@ -69,6 +75,26 @@ pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { Ok((addr.script_pubkey(), val)) } +/// Parse dns recipients in the form "test@me.com:10000,test2@from.com:40000" from cli input +pub(crate) async fn parse_dns_recipients(s: &str) -> Result, String> { + let parts: Vec<_> = s.split(',').collect(); + let mut res = vec![]; + + for addr_amount in parts { + let split: Vec<_> = addr_amount.split(':').collect(); + if split.len() != 2 { + return Err("Invalid format".to_string()); + } + let sending_amount = Amount::from_sat(u64::from_str(split[1]).map_err(|e| e.to_string())?); + + let resolved = resolve_dns_recipient(split[0], sending_amount, Network::Bitcoin) + .await + .map_err(|p| format!("Parse Error occured: {:?}", p).to_string())?; + res.push((sending_amount, resolved)); + } + Ok(res) +} + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] /// Parse the proxy (Socket:Port) argument from the cli input. pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> {