]> Untitled Git - bdk-cli/commitdiff
feat(dns-payment): port dns payment instruction
authorVihiga Tyonum <withtvpeter@gmail.com>
Wed, 8 Jul 2026 12:42:01 +0000 (13:42 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Wed, 8 Jul 2026 17:47:51 +0000 (18:47 +0100)
- migrate dns payment instruction feature

src/commands.rs
src/handlers/dns.rs [new file with mode: 0644]
src/handlers/mod.rs
src/handlers/offline.rs
src/main.rs
src/utils/common.rs

index 0af480a9155d1573ff840a868b0c9f17a5286208..b9d1206cee70b1f6b76dd9690679457cd731c0f4 100644 (file)
@@ -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 (file)
index 0000000..18e127b
--- /dev/null
@@ -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<AppContext<Init>> for ResolveDnsRecipientCommand {
+    type Output = StatusResult;
+
+    async fn execute(&self, ctx: &mut AppContext<Init>) -> Result<Self::Output, Error> {
+        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<Vec<OutPoint>>,
+    #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)]
+    pub unspendable: Option<Vec<OutPoint>>,
+    #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")]
+    pub fee_rate: Option<f32>,
+    #[arg(env = "EXT_POLICY", long = "external_policy")]
+    pub external_policy: Option<String>,
+    #[arg(env = "INT_POLICY", long = "internal_policy")]
+    pub internal_policy: Option<String>,
+    #[arg(
+        env = "ADD_STRING",
+        long = "add_string",
+        short = 's',
+        conflicts_with = "add_data"
+    )]
+    pub add_string: Option<String>,
+    #[arg(
+        env = "ADD_DATA",
+        long = "add_data",
+        short = 'o',
+        conflicts_with = "add_string"
+    )]
+    pub add_data: Option<String>,
+}
+
+impl AsyncAppCommand<AppContext<OfflineOperations<'_>>> for CreateDnsTxCommand {
+    type Output = PsbtResult;
+
+    async fn execute(
+        &self,
+        ctx: &mut AppContext<OfflineOperations<'_>>,
+    ) -> Result<Self::Output, Error> {
+        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::<BTreeMap<String, Vec<usize>>>(policy)?;
+            tx_builder.policy_path(policy, keychain);
+        }
+
+        let psbt = tx_builder.finish()?;
+        Ok(PsbtResult::new(&psbt, false, Some(false)))
+    }
+}
index 41586ea8b29e3e2bc1383c6157dd439179138090..113398be867f33b0e40e619dee854bc6f5864de1 100644 (file)
@@ -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<C> {
     feature = "electrum",
     feature = "esplora",
     feature = "rpc",
-    feature = "cbf"
+    feature = "cbf",
+    feature = "dns_payment"
 ))]
 pub trait AsyncAppCommand<C> {
     type Output: FormatOutput;
index 8a7209145d9243a4a1b1b02e14f20310a4f9bea9..d536e18c5d275da326449a09237b03a610d9cdd3 100644 (file)
@@ -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(),
+            )),
         }
     }
 }
index e957c2ddf21acbcdc949e0af455e7fbc49cfa17e..8ee07f64bbb4d4b940d59bb3ce6ff9cf1a5cc4c6 100644 (file)
@@ -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(())
index 8a4089ab355394b20ab3b7aaad217c29b4334a59..d93ff0232c6b9a85eb65967a043105931665a8b1 100644 (file)
@@ -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))
+}