From beaca7e378bc78de355d1b398c260e1c68046749 Mon Sep 17 00:00:00 2001 From: nymius <155548262+nymius@users.noreply.github.com> Date: Wed, 20 Aug 2025 13:42:12 -0300 Subject: [PATCH] feat(silentpayments): add experimental silent-payments sending support - 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. --- Cargo.lock | 9 ++ Cargo.toml | 4 + README.md | 25 ++++++ src/commands.rs | 72 +++++++++++++++ src/error.rs | 4 + src/handlers.rs | 233 +++++++++++++++++++++++++++++++++++++++++++++++- src/utils.rs | 23 +++++ 7 files changed, 366 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 562d768..b77db7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,6 +201,7 @@ dependencies = [ "bdk_esplora", "bdk_kyoto", "bdk_redb", + "bdk_sp", "bdk_wallet", "clap", "cli-table", @@ -300,6 +301,14 @@ dependencies = [ "thiserror 2.0.17", ] +[[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" diff --git a/Cargo.toml b/Cargo.toml index 7a7324a..3fd554b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ bdk_electrum = { version = "0.23.0", optional = true } bdk_esplora = { version = "0.22.1", features = ["async-https", "tokio"], optional = true } bdk_kyoto = { version = "0.15.1", optional = true } bdk_redb = { version = "0.1.0", 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.12.23", default-features = false, optional = true } @@ -62,3 +63,6 @@ verify = [] # Extra utility tools # Compile policies compiler = [] + +# Experimental silent payment sending capabilities +silent-payments = ["dep:bdk_sp"] diff --git a/README.md b/README.md index 02a15cc..8bac667 100644 --- 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 "" + +#### 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 '' --spend_public_key '' +``` + +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 : +``` + +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 :0 ``` ## Justfile diff --git a/src/commands.rs b/src/commands.rs index 73aeff5..0b36e05 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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}, @@ -127,6 +130,19 @@ pub enum CliSubCommand { }, /// List all saved wallet configurations. Wallets, + /// 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_public_key")] + scan: bdk_sp::bitcoin::secp256k1::PublicKey, + /// The spend public key to use on the silent payment code. + #[arg(long = "spend_public_key")] + spend: bdk_sp::bitcoin::secp256k1::PublicKey, + }, } /// Wallet operation subcommands. #[derive(Debug, Subcommand, Clone, PartialEq)] @@ -339,6 +355,62 @@ pub enum OfflineWalletSubCommand { )] add_data: Option, //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>, + /// 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>, + /// Marks a utxo as unspendable. + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + unspendable: Option>, + /// Fee rate to use in sat/vbyte. + #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] + fee_rate: Option, + /// Selects which policy should be used to satisfy the external descriptor. + #[arg(env = "EXT_POLICY", long = "external_policy")] + external_policy: Option, + /// Selects which policy should be used to satisfy the internal descriptor. + #[arg(env = "INT_POLICY", long = "internal_policy")] + internal_policy: Option, + /// 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, + /// 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, //base 64 econding + }, /// Bumps the fees of an RBF transaction. BumpFee { /// TXID of the transaction to update. diff --git a/src/error.rs b/src/error.rs index 3690d4f..11f1144 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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), diff --git a/src/handlers.rs b/src/handlers.rs index f6d4285..26c8ae7 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -48,6 +48,15 @@ use bdk_wallet::{ }; 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; @@ -330,7 +339,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 = 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::>>(&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 = [(pk, prv.key)].into(); + derive_sp(&mut psbt, &keys, &sp_recipients, &secp) + .expect("will fix later"); + } + SinglePubKey::XOnly(xonly) => { + let keys: HashMap = + [(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, @@ -371,7 +558,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 { @@ -379,10 +568,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); } @@ -890,6 +1085,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 { + 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`]. @@ -1338,6 +1557,12 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { 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, diff --git a/src/utils.rs b/src/utils.rs index 76e56a0..eb6400b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -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::() + .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> {