From: Vihiga Tyonum Date: Wed, 8 Jul 2026 12:42:01 +0000 (+0100) Subject: feat(dns-payment): port dns payment instruction X-Git-Url: http://internal-gitweb-vhost/blockdata/script/encode/-script/display/FromScriptException.UnrecognizedScript.html?a=commitdiff_plain;h=5951c17152d64f44e5a38deaf369ce8795b81893;p=bdk-cli feat(dns-payment): port dns payment instruction - migrate dns payment instruction feature --- diff --git a/src/commands.rs b/src/commands.rs index 0af480a..b9d1206 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -53,7 +53,8 @@ use clap::{Args, Parser, Subcommand, value_parser}; use clap_complete::Shell; #[cfg(feature = "dns_payment")] -use crate::utils::parse_dns_recipient; +use crate::handlers::dns::{CreateDnsTxCommand, ResolveDnsRecipientCommand}; + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] use crate::utils::parse_proxy_auth; @@ -201,6 +202,9 @@ pub enum CliSubCommand { /// Useful to create silent payment transactions using fake silent payment codes. #[cfg(feature = "silent-payments")] SilentPaymentCode(SilentPaymentCodeCommand), + /// Resolves BIP-353 DNS payment instructions for a human-readable name. + #[cfg(feature = "dns_payment")] + ResolveDnsRecipient(ResolveDnsRecipientCommand), } /// Wallet operation subcommands. @@ -362,6 +366,9 @@ pub enum OfflineWalletSubCommand { /// Verify a BIP322 signature #[cfg(feature = "bip322")] VerifyMessage(VerifyMessageCommand), + /// Creates a new unsigned transaction from DNS payment instructions. + #[cfg(feature = "dns_payment")] + CreateDnsTx(CreateDnsTxCommand), } /// Wallet subcommands that needs a blockchain backend. diff --git a/src/handlers/dns.rs b/src/handlers/dns.rs new file mode 100644 index 0000000..18e127b --- /dev/null +++ b/src/handlers/dns.rs @@ -0,0 +1,173 @@ +use crate::dns_payment_instructions::{ + parse_dns_instructions, process_instructions, resolve_dns_recipient, +}; +use crate::error::BDKCliError as Error; +use crate::handlers::{AppContext, AsyncAppCommand, Init, OfflineOperations}; +use crate::utils::types::{PsbtResult, StatusResult}; +use crate::utils::{parse_dns_recipient, parse_outpoint, parse_recipient}; +use bdk_wallet::KeychainKind; +use bdk_wallet::bitcoin::base64::Engine; +use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; +use bdk_wallet::bitcoin::script::PushBytesBuf; +use bdk_wallet::bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, Sequence}; +use clap::Parser; +use std::collections::BTreeMap; + +/// Resolves BIP-353 DNS payment instructions for a human-readable name. +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct ResolveDnsRecipientCommand { + /// Human-readable name (e.g. user@domain.com) + pub hrn: String, + /// DNS resolver address + #[arg(long, default_value = "8.8.8.8")] + pub resolver: String, +} + +impl AsyncAppCommand> for ResolveDnsRecipientCommand { + type Output = StatusResult; + + async fn execute(&self, ctx: &mut AppContext) -> Result { + let resolved = resolve_dns_recipient(&self.hrn, ctx.network, &self.resolver) + .await + .map_err(|e| Error::Generic(format!("{:?}", e)))?; + Ok(StatusResult { + message: resolved.display()?, + }) + } +} + +/// Creates a new unsigned transaction from DNS payment instructions. +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct CreateDnsTxCommand { + #[arg(env = "ADDRESS:SAT", long = "to", value_parser = parse_recipient)] + pub recipients: Vec<(ScriptBuf, u64)>, + #[arg(long = "to_dns", value_parser = parse_dns_recipient)] + pub dns_recipients: Vec<(String, u64)>, + #[arg(long = "dns_resolver", default_value = "8.8.8.8")] + pub dns_resolver: String, + #[arg(long = "send_all", short = 'a')] + pub send_all: bool, + #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] + pub enable_rbf: bool, + #[arg(long = "offline_signer")] + pub offline_signer: bool, + #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + pub utxos: Option>, + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + pub unspendable: Option>, + #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] + pub fee_rate: Option, + #[arg(env = "EXT_POLICY", long = "external_policy")] + pub external_policy: Option, + #[arg(env = "INT_POLICY", long = "internal_policy")] + pub internal_policy: Option, + #[arg( + env = "ADD_STRING", + long = "add_string", + short = 's', + conflicts_with = "add_data" + )] + pub add_string: Option, + #[arg( + env = "ADD_DATA", + long = "add_data", + short = 'o', + conflicts_with = "add_string" + )] + pub add_data: Option, +} + +impl AsyncAppCommand>> for CreateDnsTxCommand { + type Output = PsbtResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let network = ctx.network; + let mut recipients: Vec<(ScriptBuf, u64)> = self.recipients.clone(); + + for (hrn, amount_sat) in &self.dns_recipients { + log::info!("Resolving DNS instructions for recipient {hrn}"); + let amount = Amount::from_sat(*amount_sat); + let (resolver, instructions) = parse_dns_instructions(hrn, network, &self.dns_resolver) + .await + .map_err(|e| Error::Generic(format!("Parsing error occured {e:#?}")))?; + let payment = process_instructions(amount, &instructions, resolver).await?; + recipients.push((payment.0.into(), payment.1.to_sat())); + } + + if recipients.is_empty() { + return Err(Error::Generic( + "Either --to or --to_dns parameters must be specified".to_string(), + )); + } + + let mut tx_builder = ctx.state.wallet.build_tx(); + + if self.send_all { + if recipients.len() == 1 { + tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); + } else { + return Err(Error::Generic( + "Wallet can only be drained to a single output".to_string(), + )); + } + } else { + let recipients = recipients + .into_iter() + .map(|(script, amount)| (script, Amount::from_sat(amount))) + .collect(); + tx_builder.set_recipients(recipients); + } + + if !self.enable_rbf { + tx_builder.set_exact_sequence(Sequence::MAX); + } + if self.offline_signer { + tx_builder.include_output_redeem_witness_script(); + } + if let Some(fee_rate) = self.fee_rate + && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) + { + tx_builder.fee_rate(fee_rate); + } + if let Some(utxos) = &self.utxos { + tx_builder + .add_utxos(&utxos[..]) + .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?; + } + if let Some(unspendable) = &self.unspendable { + tx_builder.unspendable(unspendable.to_vec()); + } + if let Some(base64_data) = &self.add_data { + let op_return_data = BASE64_STANDARD + .decode(base64_data) + .map_err(|e| Error::Generic(e.to_string()))?; + tx_builder.add_data( + &PushBytesBuf::try_from(op_return_data) + .map_err(|e| Error::Generic(e.to_string()))?, + ); + } else if let Some(string_data) = &self.add_string { + let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()) + .map_err(|e| Error::Generic(e.to_string()))?; + tx_builder.add_data(&data); + } + + let policies = vec![ + self.external_policy + .as_ref() + .map(|p| (p, KeychainKind::External)), + self.internal_policy + .as_ref() + .map(|p| (p, KeychainKind::Internal)), + ]; + for (policy, keychain) in policies.into_iter().flatten() { + let policy = serde_json::from_str::>>(policy)?; + tx_builder.policy_path(policy, keychain); + } + + let psbt = tx_builder.finish()?; + Ok(PsbtResult::new(&psbt, false, Some(false))) + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 41586ea..113398b 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,7 @@ pub mod config; pub mod descriptor; +#[cfg(feature = "dns_payment")] +pub mod dns; pub mod key; pub mod offline; pub mod online; @@ -104,7 +106,8 @@ pub trait AppCommand { feature = "electrum", feature = "esplora", feature = "rpc", - feature = "cbf" + feature = "cbf", + feature = "dns_payment" ))] pub trait AsyncAppCommand { type Output: FormatOutput; diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 8a72091..d536e18 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -82,6 +82,11 @@ impl OfflineWalletSubCommand { Self::VerifyMessage(verify_message_command) => verify_message_command .execute(ctx)? .write_out(std::io::stdout()), + #[cfg(feature = "dns_payment")] + Self::CreateDnsTx(_) => Err(Error::Generic( + "CreateDnsTx is dispatched asynchronously through main" + .to_string(), + )), } } } diff --git a/src/main.rs b/src/main.rs index e957c2d..8ee07f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,8 @@ use log::{debug, warn}; use crate::commands::{CliOpts, CliSubCommand, WalletSubCommand}; use crate::error::BDKCliError as Error; +#[cfg(feature = "dns_payment")] +use crate::handlers::AsyncAppCommand; use crate::handlers::{AppCommand, AppContext}; use crate::utils::output::FormatOutput; use crate::utils::runtime::WalletRuntime; @@ -102,7 +104,16 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { &mut wallet, ); - cmd.execute(&mut ctx)?; + match cmd { + #[cfg(feature = "dns_payment")] + commands::OfflineWalletSubCommand::CreateDnsTx(dns_cmd) => { + dns_cmd + .execute(&mut ctx) + .await? + .write_out(std::io::stdout())?; + } + other => other.execute(&mut ctx)?, + } } wallet.persist()?; } @@ -206,6 +217,11 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; } + #[cfg(feature = "dns_payment")] + CliSubCommand::ResolveDnsRecipient(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + cmd.execute(&mut ctx).await?.write_out(std::io::stdout())?; + } } Ok(()) diff --git a/src/utils/common.rs b/src/utils/common.rs index 8a4089a..d93ff02 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -243,6 +243,8 @@ pub fn command_requires_db(command: &OfflineWalletSubCommand) -> bool { OfflineWalletSubCommand::VerifyMessage(_) => true, #[cfg(feature = "silent-payments")] OfflineWalletSubCommand::CreateSpTx(_) => true, + #[cfg(feature = "dns_payment")] + OfflineWalletSubCommand::CreateDnsTx(_) => true, } } @@ -306,3 +308,14 @@ pub fn print_wallet_events(events: &[WalletEvent]) { } } } + +#[cfg(feature = "dns_payment")] +/// Parse dns recipients in the form "test@me.com:10000" from cli input +pub(crate) fn parse_dns_recipient(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)) +}