From 4852673487b75e7f042dad70623e324cd639893f Mon Sep 17 00:00:00 2001 From: Sonkeng Maldini Date: Wed, 7 Jan 2026 22:56:11 +0100 Subject: [PATCH] feat: integrate BIP 353 dns payment instructions --- Cargo.lock | 93 +++++++++++ Cargo.toml | 4 +- src/commands.rs | 15 ++ src/dns_payment_instructions.rs | 286 ++++++++++++++++++++++++++++++++ src/handlers.rs | 55 +++++- src/main.rs | 3 + src/utils.rs | 16 +- 7 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 src/dns_payment_instructions.rs diff --git a/Cargo.lock b/Cargo.lock index 98036e6..345fd81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,6 +202,7 @@ dependencies = [ "bdk_kyoto", "bdk_redb", "bdk_wallet", + "bitcoin-payment-instructions", "clap", "cli-table", "dirs", @@ -466,6 +467,19 @@ dependencies = [ "toml 0.5.11", ] +[[package]] +name = "bitcoin-payment-instructions" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c300c948b2ff78c965ea3a613372352125448a22f1acf49e95e3878149824091" +dependencies = [ + "bitcoin", + "dnssec-prover", + "getrandom 0.3.4", + "lightning", + "lightning-invoice", +] + [[package]] name = "bitcoin-units" version = "0.1.2" @@ -920,6 +934,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.1", + "tokio", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1198,6 +1222,12 @@ dependencies = [ "zerocopy", ] +[[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" @@ -1631,6 +1661,12 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[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.12" @@ -1652,6 +1688,54 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lightning" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c90397b635e3ece6b9a723fb470a46cb9b3592f217d72e40540a5fada00289d" +dependencies = [ + "bech32", + "bitcoin", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice", + "lightning-macros", + "lightning-types", + "possiblyrandom", +] + +[[package]] +name = "lightning-invoice" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85e5e14bcdb30d746e9785b04f27938292e8944f78f26517e01e91691f6b3f2" +dependencies = [ + "bech32", + "bitcoin", + "lightning-types", +] + +[[package]] +name = "lightning-macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "lightning-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1aac93f22f2c2eac8a0ee83bb1a1ea58673caa2c82847302710b83364d04e6" +dependencies = [ + "bitcoin", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1942,6 +2026,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.17", +] + [[package]] name = "potential_utf" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index b7d610a..5ce5253 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,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.7.0", optional = true} [features] default = ["repl", "sqlite"] @@ -51,7 +52,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 14ad9ea..b511229 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -13,12 +13,15 @@ //! All subcommands are defined in the below enums. #![allow(clippy::large_enum_variant)] + use bdk_wallet::bitcoin::{ Address, Network, OutPoint, ScriptBuf, bip32::{DerivationPath, Xpriv}, }; use clap::{Args, Parser, Subcommand, ValueEnum, value_parser}; +#[cfg(feature = "dns_payment")] +use crate::utils::parse_dns_recipients; #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] use crate::utils::parse_proxy_auth; use crate::utils::{parse_address, parse_outpoint, parse_recipient}; @@ -127,6 +130,10 @@ pub enum CliSubCommand { }, /// List all saved wallet configurations. Wallets, + + #[cfg(feature = "dns_payment")] + /// Resolves the given hrn payment instructions + ResolveDnsRecipient { hrn: String , resolver: Option}, } /// Wallet operation subcommands. #[derive(Debug, Subcommand, Clone, PartialEq)] @@ -298,6 +305,14 @@ 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)>, + #[cfg(feature = "dns_payment")] + /// Adds DNS recipients to the transaction + #[arg(long = "to_dns", value_parser = parse_dns_recipients)] + dns_recipients: Vec<(String, u64)>, + #[cfg(feature = "dns_payment")] + /// Custom resolver DNS IP to be used for resolution. + #[arg(long = "dns_resolver", default_value = "8.8.8.8:53")] + dns_resolver: String, /// 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..459d2bc --- /dev/null +++ b/src/dns_payment_instructions.rs @@ -0,0 +1,286 @@ +use bdk_wallet::bitcoin::{Address, Amount, Network}; +use bitcoin_payment_instructions::{ + FixedAmountPaymentInstructions, ParseError, PaymentInstructions, PaymentMethod, + PaymentMethodType, amount, dns_resolver::DNSHrnResolver, +}; +use cli_table::{Cell, Style, Table, format::Justify}; +use core::{net::SocketAddr, str::FromStr}; + +use crate::{error::BDKCliError as Error, utils::shorten}; + +#[derive(Debug)] +pub struct Payment { + pub payment_methods: Vec, + pub min_amount: Option, + pub max_amount: Option, + pub description: Option, + pub expected_amount: Option, + pub receiving_addr: Option
, + pub hrn: String, + pub notes: String, +} + +impl Payment { + + pub fn display(&self, pretty: bool) -> Result { + let mut methods: Vec = Vec::new(); + self.payment_methods.iter().for_each(|pm| match pm { + bitcoin_payment_instructions::PaymentMethod::LightningBolt11(bolt11) => { + methods.push(format!("Bolt 11 invoice ({})", shorten(bolt11, 20, 15))) + } + bitcoin_payment_instructions::PaymentMethod::LightningBolt12(offer) => { + methods.push(format!("Bolt 12 invoice ({})", shorten(offer, 20, 15))) + } + bitcoin_payment_instructions::PaymentMethod::OnChain(address) => { + methods.push(format!("On chain ({})", address)) + } + bitcoin_payment_instructions::PaymentMethod::Cashu(csh) => { + methods.push(format!("Cashu payment ({})", shorten(csh, 20, 15))) + } + }); + + if pretty { + let mut table = vec![vec![ + "HRN".cell().bold(true), + self.hrn.to_string().cell().justify(Justify::Right), + ]]; + + if let Some(min_amnt) = self.min_amount { + table.push(vec![ + "Min amount".cell().bold(true), + min_amnt.to_string().cell().justify(Justify::Right), + ]); + } + + if let Some(max_amnt) = self.max_amount { + table.push(vec![ + "Max amount".cell().bold(true), + max_amnt.to_string().cell().justify(Justify::Right), + ]); + } + + if let Some(send_amnt) = self.expected_amount { + table.push(vec![ + "Expected Amount to send".cell().bold(true), + send_amnt.to_string().cell().justify(Justify::Right), + ]); + } + + if let Some(descr) = &self.description { + table.push(vec![ + "Description".cell().bold(true), + descr.cell().justify(Justify::Right), + ]); + } + + table.push(vec![ + "Accepted methods".cell().bold(true), + methods.join(", ").cell().justify(Justify::Right), + ]); + table.push(vec![ + "Notes".cell().bold(true), + self.notes.clone().cell().justify(Justify::Right), + ]); + + let table = table + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty(&serde_json::json!({ + "hrn": self.hrn, + "payment_methods": methods, + "description": self.description, + "min_amount": self.min_amount, + "max_amount": self.max_amount, + "expected_amount_to_send": self.expected_amount, + "notes": self.notes + }))?) + } + } +} +pub(crate) async fn parse_dns_instructions( + hrn: &str, + network: Network, + resolver_ip: Option +) -> Result<(DNSHrnResolver, PaymentInstructions), ParseError> { + + let mut ip_address = "8.8.8.8:53".to_string(); + if let Some(res_addr) = resolver_ip { + ip_address = res_addr; + } + + let resolver = DNSHrnResolver(SocketAddr::from_str(&ip_address).expect("Should not fail.")); + let instructions = PaymentInstructions::parse(hrn, network, &resolver, true).await?; + Ok((resolver, instructions)) +} + +fn get_onchain_info( + instructions: &FixedAmountPaymentInstructions, +) -> Result<(Address, Amount), Error> { + // 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(_))) + .ok_or(Error::Generic( + "Missing Onchain payment method option.".to_string(), + ))? + else { + return Err(Error::Generic( + "Unsupported payment method".to_string(), + )); + }; + + let Some(onchain_amount) = instructions.onchain_payment_amount() else { + return Err(Error::Generic( + "On chain amount should be specified".to_string(), + )); + }; + + // We need this conversion since Amount from instructions is different from Amount from bitcoin + Ok((addr.clone(), Amount::from_sat(onchain_amount.milli_sats()))) +} + +pub async fn process_instructions( + amount_to_send: Amount, + payment_instructions: &PaymentInstructions, + resolver: DNSHrnResolver +) -> Result { + + match payment_instructions { + PaymentInstructions::ConfigurableAmount(instructions) => { + // Look for on chain payment method as it's the only one we can support + if !instructions + .methods() + .any(|method| matches!(method.method_type(), PaymentMethodType::OnChain)) + { + return Err(Error::Generic( + "Unsupported payment method".to_string(), + )); + } + + let min_amount = instructions + .min_amt() + .map(|amnt| Amount::from_sat(amnt.milli_sats())); + + let max_amount = instructions + .max_amt() + .map(|amnt| Amount::from_sat(amnt.milli_sats())); + + if min_amount.is_some_and(|min| amount_to_send < min) { + return Err(Error::Generic( + format!( + "Amount to send should be greater than min {}", + min_amount.unwrap() + ) + .to_string(), + )); + } + + if max_amount.is_some_and(|max| amount_to_send > max) { + return Err(Error::Generic( + format!( + "Amount to send should be lower than max {}", + max_amount.unwrap() + ) + .to_string(), + )); + } + + let fixed_instructions = instructions + .clone() + .set_amount( + amount::Amount::from_sats(amount_to_send.to_sat()).unwrap(), + &resolver, + ) + .await + .map_err(|err| { + Error::Generic(format!("Error occured while parsing instructions {err}")) + })?; + + let onchain_details = get_onchain_info(&fixed_instructions)?; + + Ok(Payment { + payment_methods: vec![PaymentMethod::OnChain(onchain_details.clone().0)], + min_amount, + max_amount, + description: instructions.recipient_description().map(|s| s.to_string()), + expected_amount: Some(onchain_details.1), + receiving_addr: Some(onchain_details.0.clone()), + hrn: instructions.human_readable_name().unwrap().to_string(), + notes: "".to_string() + }) + } + + PaymentInstructions::FixedAmount(instructions) => { + let onchain_info = get_onchain_info(instructions)?; + + Ok(Payment { + payment_methods: vec![PaymentMethod::OnChain(onchain_info.clone().0)], + min_amount: None, + max_amount: instructions + .max_amount() + .map(|amnt| Amount::from_sat(amnt.milli_sats())), + description: instructions.recipient_description().map(|s| s.to_string()), + expected_amount: Some(onchain_info.1), + receiving_addr: Some(onchain_info.0), + notes: "".to_string(), + hrn: instructions.human_readable_name().unwrap().to_string(), + }) + } + } +} + +/// Resolves the dns payment instructions found at the specified Human Readable Name +pub async fn resolve_dns_recipient(hrn: &str, network: Network, ip: Option) -> Result { + let (resolver, instructions) = parse_dns_instructions(hrn, network, ip).await?; + + match instructions { + PaymentInstructions::ConfigurableAmount(ix) => { + let description = ix.recipient_description().map(|s| s.to_string()); + let min_amount = ix.min_amt().map(|amnt| Amount::from_sat(amnt.milli_sats())); + let max_amount = ix.max_amt().map(|amnt| Amount::from_sat(amnt.milli_sats())); + + // Let's set a dummy amount to resolve the payment methods accepted. + let fixed_instructions = ix + .set_amount(amount::Amount::ZERO, &resolver) + .await + .map_err(ParseError::InvalidInstructions)?; + + let payment = Payment { + min_amount, + max_amount, + payment_methods: fixed_instructions.methods().into(), + description, + expected_amount: None, + receiving_addr: None, + hrn: hrn.to_string(), + notes: "This is configurable payment instructions. You must send an amount between min_amount and max_amount if set.".to_string() + }; + + Ok(payment) + } + + PaymentInstructions::FixedAmount(ix) => { + let max_amount = ix + .max_amount() + .map(|amnt| Amount::from_sat(amnt.milli_sats())); + + let payment = Payment { + min_amount: None, + max_amount, + payment_methods: ix.methods().into(), + description: ix.recipient_description().map(|s| s.to_string()), + expected_amount: None, + receiving_addr: None, + hrn: hrn.to_string(), + notes: "This is a fixed payment instructions. You must send exactly the amount specified in max_amount.".to_string() + }; + + Ok(payment) + } + } +} diff --git a/src/handlers.rs b/src/handlers.rs index 1f867b4..5ba7848 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -12,6 +12,8 @@ use crate::commands::OfflineWalletSubCommand::*; use crate::commands::*; use crate::config::{WalletConfig, WalletConfigInner}; +#[cfg(feature = "dns_payment")] +use crate::dns_payment_instructions::resolve_dns_recipient; use crate::error::BDKCliError as Error; #[cfg(any(feature = "sqlite", feature = "redb"))] use crate::persister::Persister; @@ -19,6 +21,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::{ @@ -97,7 +100,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, @@ -333,6 +336,10 @@ pub fn handle_offline_wallet_subcommand( CreateTx { recipients, + #[cfg(feature = "dns_payment")] + dns_recipients, + #[cfg(feature = "dns_payment")] + dns_resolver, send_all, enable_rbf, offline_signer, @@ -349,10 +356,26 @@ pub fn handle_offline_wallet_subcommand( if send_all { tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); } else { - let recipients = recipients + #[allow(unused_mut)] + let mut recipients: Vec<(ScriptBuf, Amount)> = recipients .into_iter() .map(|(script, amount)| (script, Amount::from_sat(amount))) .collect(); + + #[cfg(feature = "dns_payment")] + for recipient in dns_recipients { + use crate::dns_payment_instructions::{ + parse_dns_instructions, process_instructions, + }; + let amount = Amount::from_sat(recipient.1); + let (resolver, instructions) = parse_dns_instructions(&recipient.0, cli_opts.network, Some(dns_resolver.clone())) + .await + .map_err(|e| Error::Generic(format!("Parsing error occured {e:#?}")))?; + let payment = process_instructions(amount, &instructions, resolver).await?; + + recipients.push((payment.receiving_addr.unwrap().into(), amount)); + } + tx_builder.set_recipients(recipients); } @@ -1060,6 +1083,20 @@ pub(crate) fn handle_compile_subcommand( } } +#[cfg(feature = "dns_payment")] +pub(crate) async fn handle_resolve_dns_recipient_command( + pretty: bool, + hrn: &str, + resolver: Option, + network: Network, +) -> Result { + let resolved = resolve_dns_recipient(hrn, network, resolver) + .await + .map_err(|e| Error::Generic(format!("{:?}", e)))?; + + resolved.display(pretty) +} + /// Handle wallets command to show all saved wallet configurations pub fn handle_wallets_subcommand(datadir: &Path, pretty: bool) -> Result { let load_config = WalletConfig::load(datadir)?; @@ -1300,7 +1337,8 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { &wallet_opts, &cli_opts, offline_subcommand.clone(), - )?; + ) + .await?; wallet.persist(&mut persister)?; result }; @@ -1312,7 +1350,8 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { &wallet_opts, &cli_opts, offline_subcommand.clone(), - )? + ) + .await? }; Ok(result) } @@ -1422,6 +1461,13 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { let descriptor = handle_descriptor_command(cli_opts.network, desc_type, key, pretty)?; Ok(descriptor) } + + #[cfg(feature = "dns_payment")] + CliSubCommand::ResolveDnsRecipient { hrn, resolver } => { + let res = handle_resolve_dns_recipient_command(cli_opts.pretty, &hrn, resolver, cli_opts.network) + .await?; + Ok(res) + } }; result } @@ -1462,6 +1508,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 90d701b..6b7745c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,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 73d3453..f85732b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -12,10 +12,7 @@ use crate::config::WalletConfig; use crate::error::BDKCliError as Error; use std::{ - fmt::Display, - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, + fmt::Display, path::{Path, PathBuf}, str::FromStr, sync::Arc }; use crate::commands::WalletOpts; @@ -70,6 +67,17 @@ pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { Ok((addr.script_pubkey(), val)) } +#[cfg(feature = "dns_payment")] +/// Parse dns recipients in the form "test@me.com:10000" from cli input +pub(crate) fn parse_dns_recipients(s: &str) -> Result<(String, u64), String> { + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 2 { + return Err("Invalid format".to_string()); + } + let sending_amount = u64::from_str(parts[1]).map_err(|e| e.to_string())?; + Ok((parts[0].to_string(), sending_amount)) +} + #[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> {