]> Untitled Git - bdk-cli/commitdiff
feat(silentpayments): add experimental silent-payments sending support
authornymius <155548262+nymius@users.noreply.github.com>
Wed, 20 Aug 2025 16:42:12 +0000 (13:42 -0300)
committernymius <155548262+nymius@users.noreply.github.com>
Mon, 11 May 2026 13:58:18 +0000 (10:58 -0300)
- Adds CreateSpTx command to create transactions with silent payment
  outputs: this command creates signed transactions directly rather than
  PSBTs due to current limitations in secure shared derivation.

  It supports mixed recipients: regular addresses + silent payments.
  It DOES NOT support RBF for the created transactions.
  It generates signed transactions ready for broadcasting.

- Adds SilentPaymentCode command to create silent payment codes from
  public keys and network: the silent payment code generated is
  independent from any of the other stateful features of bdk-cli.

  This command is mainly intended for experimental use, do not lock any
  funds to the generated code if you don't know what you are doing and
  don't have the keys matching the public keys used.

- Adds bdk_sp dependency with "silent-payments" feature flag.
- Adds silent payment recipient parsing utility.
- Add README section for new silent payment commands.

Note: This is experimental functionality for testing only, not
recommended for mainnet use.

Signed-off-by: nymius <155548262+nymius@users.noreply.github.com>
Cargo.lock
Cargo.toml
README.md
src/commands.rs
src/error.rs
src/handlers.rs
src/utils.rs

index b491c2e6af447c32adcf4124b3c66111d3ffed01..c66f89ee91870a6cc3d33368e2d05de3a7588880 100644 (file)
@@ -201,6 +201,7 @@ dependencies = [
  "bdk_esplora",
  "bdk_kyoto",
  "bdk_redb",
+ "bdk_sp",
  "bdk_wallet",
  "clap",
  "clap_complete",
@@ -301,6 +302,14 @@ dependencies = [
  "thiserror 2.0.18",
 ]
 
+[[package]]
+name = "bdk_sp"
+version = "0.1.0"
+source = "git+https://github.com/bitcoindevkit/bdk-sp?tag=v0.1.0#79cfaf1e8829dd771c4461e6cd2a46c8abb00503"
+dependencies = [
+ "bitcoin",
+]
+
 [[package]]
 name = "bdk_wallet"
 version = "2.1.0"
index 7a1a31b46f7bc8527a13a33ae2982d4e6cc05775..63a097ce44044b83c0d7fb2e209c6f981c9265f7 100644 (file)
@@ -33,6 +33,7 @@ bdk_electrum = { version = "0.23.2", optional = true }
 bdk_esplora = { version = "0.22.1", features = ["async-https", "tokio"], optional = true }
 bdk_kyoto = { version = "0.15.4", optional = true }
 bdk_redb = { version = "0.1.1", optional = true }
+bdk_sp = { version = "0.1.0", optional = true, git = "https://github.com/bitcoindevkit/bdk-sp", tag = "v0.1.0" }
 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.13.2", default-features = false, optional = true }
@@ -63,3 +64,6 @@ verify = []
 # Extra utility tools
 # Compile policies
 compiler = []
+
+# Experimental silent payment sending capabilities
+silent-payments = ["dep:bdk_sp"]
index fd01432cd574531425ba3c3a355912500faac60b..2073a67b1d3578af88cc41a206885648272120a8 100644 (file)
--- a/README.md
+++ b/README.md
@@ -148,6 +148,31 @@ cargo run --features rpc -- wallet --wallet payjoin_wallet2 sync
 cargo run --features rpc -- wallet --wallet payjoin_wallet2 balance
 
 cargo run --features rpc -- wallet --wallet payjoin_wallet2 send_payjoin --ohttp_relay "https://pj.bobspacebkk.com" --ohttp_relay "https://pj.benalleng.com" --fee_rate 1 --uri "<URI>"
+
+#### Silent payments
+
+> [!WARNING]
+> This tool does not support silent payment scanning, nor the `silent_payment_code`
+> command has any control on the public keys provided. If you don't have access
+> to a silent payment scanner with the keys you provided, you are not going to
+> be able to discover any funds, and if you do not control the private keys,
+> you are not going to be able to spend the funds. We do not recommend the use
+> of any of the silent payment features with real funds.
+
+To experiment with silent payments, you can get two public keys in compressed or uncompressed format, `A1` and `A2`, and produce a silent payment code by calling:
+```shell
+cargo run --features sp -- --network signet silent_payment_code --scan_public_key '<A1>' --spend_public_key '<A2>'
+```
+
+Once you have a silent payment code, `SP_CODE_1` and an amount `AMOUNT_1` to send, you can create a valid transaction locking funds to a silent payment code derived address with the following command:
+
+```shell
+cargo run --features electrum,sp -- --network testnet4 wallet --wallet sample_wallet --ext-descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" --database-type sqlite --client-type electrum --url "ssl://mempool.space:40002" create_sp_tx --to-sp <SP_CODE_1>:<AMOUNT_1>
+```
+
+It's also possible to drain all balance to a silent payment wallet by using the `--send_all` flag:
+```shell
+cargo run --features electrum,sp -- --network testnet4 wallet --wallet sample_wallet --ext-descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" --database-type sqlite --client-type electrum --url "ssl://mempool.space:40002" create_sp_tx --send_all --to-sp <SP_CODE_1>:0
 ```
 
 ## Justfile
index 44b13f270b7b8a73a654507c83591c29c93133dc..df582664248d36f3d4525d418ea1873273862015 100644 (file)
@@ -13,6 +13,9 @@
 //! All subcommands are defined in the below enums.
 
 #![allow(clippy::large_enum_variant)]
+#[cfg(feature = "silent-payments")]
+use {crate::utils::parse_sp_code_value_pairs, bdk_sp::encoding::SilentPaymentCode};
+
 use bdk_wallet::bitcoin::{
     Address, Network, OutPoint, ScriptBuf,
     bip32::{DerivationPath, Xpriv},
@@ -182,6 +185,19 @@ pub enum CliSubCommand {
         #[arg(value_enum)]
         shell: Shell,
     },
+    /// Silent payment code generation tool.
+    ///
+    /// Allows the encoding of two public keys into a silent payment code.
+    /// Useful to create silent payment transactions using fake silent payment codes.
+    #[cfg(feature = "silent-payments")]
+    SilentPaymentCode {
+        /// The scan public key to use on the silent payment code.
+        #[arg(long = "scan_key")]
+        scan: bdk_sp::bitcoin::secp256k1::PublicKey,
+        /// The spend public key to use on the silent payment code.
+        #[arg(long = "spend_key")]
+        spend: bdk_sp::bitcoin::secp256k1::PublicKey,
+    },
 }
 
 /// Wallet operation subcommands.
@@ -395,6 +411,62 @@ pub enum OfflineWalletSubCommand {
         )]
         add_data: Option<String>, //base 64 econding
     },
+    /// Creates a silent payment transaction
+    ///
+    /// This sub-command is **EXPERIMENTAL** and should only be used for testing. Do not use this
+    /// feature to create transactions that spend actual funds on the Bitcoin mainnet.
+
+    // This command DOES NOT return a PSBT. Instead, it directly returns a signed transaction
+    // ready for broadcast, as it is not yet possible to perform a shared derivation of a silent
+    // payment script pubkey in a secure and trustless manner.
+    #[cfg(feature = "silent-payments")]
+    CreateSpTx {
+        /// Adds a recipient to the transaction.
+        // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704.
+        // Address and amount parsing is done at run time in handler function.
+        #[arg(env = "ADDRESS:SAT", long = "to", required = false, value_parser = parse_recipient)]
+        recipients: Option<Vec<(ScriptBuf, u64)>>,
+        /// Parse silent payment recipients
+        #[arg(long = "to-sp", required = true, value_parser = parse_sp_code_value_pairs)]
+        silent_payment_recipients: Vec<(SilentPaymentCode, u64)>,
+        /// 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,
+        /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output.
+        #[arg(long = "offline_signer")]
+        offline_signer: bool,
+        /// Selects which utxos *must* be spent.
+        #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)]
+        utxos: Option<Vec<OutPoint>>,
+        /// Marks a utxo as unspendable.
+        #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)]
+        unspendable: Option<Vec<OutPoint>>,
+        /// Fee rate to use in sat/vbyte.
+        #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")]
+        fee_rate: Option<f32>,
+        /// Selects which policy should be used to satisfy the external descriptor.
+        #[arg(env = "EXT_POLICY", long = "external_policy")]
+        external_policy: Option<String>,
+        /// Selects which policy should be used to satisfy the internal descriptor.
+        #[arg(env = "INT_POLICY", long = "internal_policy")]
+        internal_policy: Option<String>,
+        /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes)
+        #[arg(
+            env = "ADD_STRING",
+            long = "add_string",
+            short = 's',
+            conflicts_with = "add_data"
+        )]
+        add_string: Option<String>,
+        /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes)
+        #[arg(
+            env = "ADD_DATA",
+            long = "add_data",
+            short = 'o',
+            conflicts_with = "add_string"
+        )]
+        add_data: Option<String>, //base 64 econding
+    },
     /// Bumps the fees of an RBF transaction.
     BumpFee {
         /// TXID of the transaction to update.
index 3690d4f0193785ed3093e48045bc2ee23779fb13..11f114419564dde163aa473d21b213023bb14aeb 100644 (file)
@@ -21,6 +21,10 @@ pub enum BDKCliError {
     #[error("Create transaction error: {0}")]
     CreateTx(#[from] bdk_wallet::error::CreateTxError),
 
+    #[cfg(feature = "silent-payments")]
+    #[error("Silent payment address decoding error: {0}")]
+    SilentPaymentParseError(#[from] bdk_sp::encoding::ParseError),
+
     #[error("Descriptor error: {0}")]
     DescriptorError(#[from] bdk_wallet::descriptor::error::Error),
 
index a98b172dff82e81c51a9002d8b7213c8f166ffef..96affeeedfa157f99e1b7d983c33380be737011c 100644 (file)
@@ -49,6 +49,15 @@ use bdk_wallet::{
 use clap::CommandFactory;
 use cli_table::{Cell, CellStruct, Style, Table, format::Justify};
 use serde_json::json;
+#[cfg(feature = "silent-payments")]
+use {
+    bdk_sp::{
+        bitcoin::{PrivateKey, PublicKey, ScriptBuf},
+        encoding::SilentPaymentCode,
+        send::psbt::derive_sp,
+    },
+    bdk_wallet::keys::{DescriptorPublicKey, DescriptorSecretKey, SinglePubKey},
+};
 
 #[cfg(feature = "electrum")]
 use crate::utils::BlockchainClient::Electrum;
@@ -331,7 +340,185 @@ pub fn handle_offline_wallet_subcommand(
                 )?)
             }
         }
+        #[cfg(feature = "silent-payments")]
+        CreateSpTx {
+            recipients: maybe_recipients,
+            silent_payment_recipients,
+            send_all,
+            offline_signer,
+            utxos,
+            unspendable,
+            fee_rate,
+            external_policy,
+            internal_policy,
+            add_data,
+            add_string,
+        } => {
+            let mut tx_builder = wallet.build_tx();
+
+            let sp_recipients: Vec<SilentPaymentCode> = silent_payment_recipients
+                .iter()
+                .map(|(sp_code, _)| sp_code.clone())
+                .collect();
+
+            if send_all {
+                if sp_recipients.len() == 1 && maybe_recipients.is_none() {
+                    tx_builder
+                        .drain_wallet()
+                        .drain_to(sp_recipients[0].get_placeholder_p2tr_spk());
+                } else if let Some(ref recipients) = maybe_recipients
+                    && sp_recipients.is_empty()
+                {
+                    if recipients.len() == 1 {
+                        tx_builder.drain_wallet().drain_to(recipients[0].0.clone());
+                    } else {
+                        return Err(Error::Generic(
+                            "Wallet can only be drain to a single output".to_string(),
+                        ));
+                    }
+                } else {
+                    return Err(Error::Generic(
+                        "Wallet can only be drain to a single output".to_string(),
+                    ));
+                }
+            } else {
+                let mut outputs: Vec<(ScriptBuf, Amount)> = silent_payment_recipients
+                    .iter()
+                    .map(|(sp_code, amount)| {
+                        let script = sp_code.get_placeholder_p2tr_spk();
+                        (script, Amount::from_sat(*amount))
+                    })
+                    .collect();
+
+                if let Some(recipients) = maybe_recipients {
+                    let recipients = recipients
+                        .into_iter()
+                        .map(|(script, amount)| (script, Amount::from_sat(amount)));
+
+                    outputs.extend(recipients);
+                }
+
+                tx_builder.set_recipients(outputs);
+            }
+
+            // Do not enable RBF for this transaction
+            tx_builder.set_exact_sequence(Sequence::MAX);
+
+            if offline_signer {
+                tx_builder.include_output_redeem_witness_script();
+            }
+
+            if let Some(fee_rate) = 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) = utxos {
+                tx_builder
+                    .add_utxos(&utxos[..])
+                    .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?;
+            }
+
+            if let Some(unspendable) = unspendable {
+                tx_builder.unspendable(unspendable);
+            }
+
+            if let Some(base64_data) = 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) = 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![
+                external_policy.map(|p| (p, KeychainKind::External)),
+                internal_policy.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 mut psbt = tx_builder.finish()?;
+
+            let unsigned_psbt = psbt.clone();
+
+            let finalized = wallet.sign(&mut psbt, SignOptions::default())?;
+
+            if !finalized {
+                return Err(Error::Generic(
+                    "Cannot produce silent payment outputs without intermediate signing phase."
+                        .to_string(),
+                ));
+            }
+
+            for (full_input, psbt_input) in unsigned_psbt.inputs.iter().zip(psbt.inputs.iter_mut())
+            {
+                // repopulate key derivation data
+                psbt_input.bip32_derivation = full_input.bip32_derivation.clone();
+                psbt_input.tap_key_origins = full_input.tap_key_origins.clone();
+            }
+
+            let secp = Secp256k1::new();
+            let mut external_signers = wallet.get_signers(KeychainKind::External).as_key_map(&secp);
+            let internal_signers = wallet.get_signers(KeychainKind::Internal).as_key_map(&secp);
+            external_signers.extend(internal_signers);
+
+            match external_signers.iter().next().expect("not empty") {
+                (DescriptorPublicKey::Single(single_pub), DescriptorSecretKey::Single(prv)) => {
+                    match single_pub.key {
+                        SinglePubKey::FullKey(pk) => {
+                            let keys: HashMap<PublicKey, PrivateKey> = [(pk, prv.key)].into();
+                            derive_sp(&mut psbt, &keys, &sp_recipients, &secp)
+                                .expect("will fix later");
+                        }
+                        SinglePubKey::XOnly(xonly) => {
+                            let keys: HashMap<bdk_sp::bitcoin::XOnlyPublicKey, PrivateKey> =
+                                [(xonly, prv.key)].into();
+                            derive_sp(&mut psbt, &keys, &sp_recipients, &secp)
+                                .expect("will fix later");
+                        }
+                    };
+                }
+                (_, DescriptorSecretKey::XPrv(k)) => {
+                    derive_sp(&mut psbt, &k.xkey, &sp_recipients, &secp).expect("will fix later");
+                }
+                _ => unimplemented!("multi xkey signer"),
+            };
 
+            // Unfinalize PSBT to resign
+            for psbt_input in psbt.inputs.iter_mut() {
+                psbt_input.final_script_sig = None;
+                psbt_input.final_script_witness = None;
+            }
+
+            let _resigned = wallet.sign(&mut psbt, SignOptions::default())?;
+
+            let raw_tx = psbt.extract_tx()?;
+            if cli_opts.pretty {
+                let table = vec![vec![
+                    "Raw Transaction".cell().bold(true),
+                    serialize_hex(&raw_tx).cell(),
+                ]]
+                .table()
+                .display()
+                .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else {
+                Ok(serde_json::to_string_pretty(
+                    &json!({"raw_tx": serialize_hex(&raw_tx)}),
+                )?)
+            }
+        }
         CreateTx {
             recipients,
             send_all,
@@ -372,7 +559,9 @@ pub fn handle_offline_wallet_subcommand(
             }
 
             if let Some(utxos) = utxos {
-                tx_builder.add_utxos(&utxos[..]).unwrap();
+                tx_builder
+                    .add_utxos(&utxos[..])
+                    .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?;
             }
 
             if let Some(unspendable) = unspendable {
@@ -380,10 +569,16 @@ pub fn handle_offline_wallet_subcommand(
             }
 
             if let Some(base64_data) = add_data {
-                let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap();
-                tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap());
+                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) = add_string {
-                let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap();
+                let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec())
+                    .map_err(|e| Error::Generic(e.to_string()))?;
                 tx_builder.add_data(&data);
             }
 
@@ -891,6 +1086,30 @@ pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> {
     Ok(())
 }
 
+#[cfg(feature = "silent-payments")]
+pub(crate) fn handle_sp_subcommand(
+    scan_pubkey: bdk_sp::bitcoin::secp256k1::PublicKey,
+    spend_pubkey: bdk_sp::bitcoin::secp256k1::PublicKey,
+    network: Network,
+    pretty: bool,
+) -> Result<String, Error> {
+    let sp_code = SilentPaymentCode::new_v0(scan_pubkey, spend_pubkey, network);
+    if pretty {
+        let table = vec![vec![
+            "sp_code".cell().bold(true),
+            sp_code.to_string().cell(),
+        ]]
+        .table()
+        .display()
+        .map_err(|e| Error::Generic(e.to_string()))?;
+        Ok(format!("{table}"))
+    } else {
+        Ok(serde_json::to_string_pretty(
+            &json!({"sp_code": sp_code.to_string()}),
+        )?)
+    }
+}
+
 /// Handle a key sub-command
 ///
 /// Key sub-commands are described in [`KeySubCommand`].
@@ -1339,6 +1558,12 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result<String, Error> {
             let result = handle_key_subcommand(network, key_subcommand, pretty)?;
             Ok(result)
         }
+        #[cfg(feature = "silent-payments")]
+        CliSubCommand::SilentPaymentCode { scan, spend } => {
+            let network = cli_opts.network;
+            let result = handle_sp_subcommand(scan, spend, network, pretty)?;
+            Ok(result)
+        }
         #[cfg(feature = "compiler")]
         CliSubCommand::Compile {
             policy,
index 76e56a0f63e9554abd4b044dcc9eb1463693df7b..eb6400b445575b43f575a86708eac33a56d19b10 100644 (file)
@@ -24,6 +24,8 @@ use bdk_kyoto::{
     BuilderExt, Info, LightClient, Receiver, ScanType::Sync, UnboundedReceiver, Warning,
     builder::Builder,
 };
+#[cfg(feature = "silent-payments")]
+use bdk_sp::encoding::SilentPaymentCode;
 use bdk_wallet::{
     KeychainKind,
     bitcoin::bip32::{DerivationPath, Xpub},
@@ -70,6 +72,27 @@ pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> {
     Ok((addr.script_pubkey(), val))
 }
 
+#[cfg(feature = "silent-payments")]
+pub(crate) fn parse_sp_code_value_pairs(s: &str) -> Result<(SilentPaymentCode, u64), Error> {
+    let parts: Vec<&str> = s.split(':').collect();
+    if parts.len() != 2 {
+        return Err(Error::Generic(format!(
+            "Invalid format '{}'. Expected 'key:value'",
+            s
+        )));
+    }
+
+    let value_0 = parts[0].trim();
+    let key = SilentPaymentCode::try_from(value_0)?;
+
+    let value = parts[1]
+        .trim()
+        .parse::<u64>()
+        .map_err(|_| Error::Generic(format!("Invalid number '{}' for key '{}'", parts[1], key)))?;
+
+    Ok((key, value))
+}
+
 #[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> {