From: Vihiga Tyonum Date: Sat, 6 Jun 2026 11:12:01 +0000 (+0100) Subject: refactor(sp-payment): Rebase silent payment feat X-Git-Url: http://internal-gitweb-vhost/blockdata/script/encode/-script/display/FromScriptException.UnrecognizedScript.html?a=commitdiff_plain;h=75b51413d9bdc4f981831cb91dc966318b0dc0a4;p=bdk-cli refactor(sp-payment): Rebase silent payment feat - rebase silent payment feature --- diff --git a/src/commands.rs b/src/commands.rs index cc4e121..d5ae053 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -26,6 +26,9 @@ use crate::handlers::{ }, }; +#[cfg(feature = "silent-payments")] +use crate::handlers::{descriptor::SilentPaymentCodeCommand, offline::CreateSpTxCommand}; + #[cfg(any( feature = "electrum", feature = "esplora", @@ -189,6 +192,12 @@ 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(SilentPaymentCodeCommand), } /// Wallet operation subcommands. @@ -321,6 +330,15 @@ pub enum OfflineWalletSubCommand { Balance(BalanceCommand), /// Creates a new unsigned transaction. CreateTx(CreateTxCommand), + /// 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(CreateSpTxCommand), /// Bumps the fees of an RBF transaction. BumpFee(BumpFeeCommand), /// Returns the available spending policies for the descriptor. @@ -329,7 +347,6 @@ pub enum OfflineWalletSubCommand { PublicDescriptor(PublicDescriptorCommand), /// Signs and tries to finalize a PSBT. Sign(SignCommand), - /// Extracts a raw transaction from a PSBT. ExtractPsbt(ExtractPsbtCommand), /// Finalizes a PSBT. diff --git a/src/handlers/descriptor.rs b/src/handlers/descriptor.rs index c0bec73..1396c88 100644 --- a/src/handlers/descriptor.rs +++ b/src/handlers/descriptor.rs @@ -21,6 +21,9 @@ use { std::{str::FromStr, sync::Arc}, }; +#[cfg(feature = "silent-payments")] +use crate::utils::types::StatusResult; + #[cfg(feature = "compiler")] const NUMS_UNSPENDABLE_KEY_HEX: &str = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; @@ -113,3 +116,28 @@ impl AppCommand> for CompileCommand { }) } } + +#[cfg(feature = "silent-payments")] +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct SilentPaymentCodeCommand { + /// 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, +} + +#[cfg(feature = "silent-payments")] +impl AppCommand> for SilentPaymentCodeCommand { + type Output = StatusResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let sp_code = + bdk_sp::encoding::SilentPaymentCode::new_v0(self.scan, self.spend, ctx.network); + + Ok(StatusResult { + message: sp_code.to_string(), + }) + } +} diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index d45a772..b441230 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -17,6 +17,18 @@ use clap::Parser; use serde_json::json; use std::collections::BTreeMap; use std::str::FromStr; +#[cfg(feature = "silent-payments")] +use { + crate::utils::common::parse_sp_code_value_pairs, + bdk_sp::{ + bitcoin::{PrivateKey, PublicKey}, + encoding::SilentPaymentCode, + send::psbt::derive_sp, + }, + bdk_wallet::bitcoin::key::Secp256k1, + bdk_wallet::keys::{DescriptorPublicKey, DescriptorSecretKey, SinglePubKey}, + std::collections::HashMap, +}; #[cfg(feature = "bip322")] use { crate::utils::parse_signature_format, @@ -41,6 +53,8 @@ impl OfflineWalletSubCommand { Self::CreateTx(createtx_command) => { createtx_command.execute(ctx)?.write_out(std::io::stdout()) } + #[cfg(feature = "silent-payments")] + Self::CreateSpTx(cmd) => cmd.execute(ctx)?.write_out(std::io::stdout()), Self::BumpFee(bumpfee_command) => { bumpfee_command.execute(ctx)?.write_out(std::io::stdout()) } @@ -293,6 +307,227 @@ impl AppCommand>> for CreateTxCommand { } } +#[cfg(feature = "silent-payments")] +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct CreateSpTxCommand { + /// 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)] + pub recipients: Option>, + /// Parse silent payment recipients + #[arg(long = "to-sp", required = true, value_parser = parse_sp_code_value_pairs)] + pub 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')] + pub 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")] + pub offline_signer: bool, + /// Selects which utxos *must* be spent. + #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + pub utxos: Option>, + /// Marks a utxo as unspendable. + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + pub unspendable: Option>, + /// Fee rate to use in sat/vbyte. + #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] + pub fee_rate: Option, + /// Selects which policy should be used to satisfy the external descriptor. + #[arg(env = "EXT_POLICY", long = "external_policy")] + pub external_policy: Option, + /// Selects which policy should be used to satisfy the internal descriptor. + #[arg(env = "INT_POLICY", long = "internal_policy")] + pub 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" + )] + pub 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" + )] + pub add_data: Option, //base 64 econding +} + +#[cfg(feature = "silent-payments")] +impl AppCommand>> for CreateSpTxCommand { + type Output = RawPsbt; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let mut tx_builder = ctx.state.wallet.build_tx(); + + let sp_recipients: Vec = self + .silent_payment_recipients + .iter() + .map(|(sp_code, _)| sp_code.clone()) + .collect(); + + if self.send_all { + if sp_recipients.len() == 1 && self.recipients.is_none() { + tx_builder + .drain_wallet() + .drain_to(sp_recipients[0].get_placeholder_p2tr_spk()); + } else if let Some(ref recipients) = self.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)> = self + .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) = &self.recipients { + let recipients = recipients + .iter() + .map(|(script, amount)| (script.clone(), 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 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 mut psbt = tx_builder.finish()?; + + let unsigned_psbt = psbt.clone(); + + let finalized = ctx.state.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 = ctx + .state + .wallet + .get_signers(KeychainKind::External) + .as_key_map(&secp); + let internal_signers = ctx + .state + .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 = ctx.state.wallet.sign(&mut psbt, SignOptions::default())?; + + let raw_tx = psbt.extract_tx()?; + + Ok(RawPsbt::new(&raw_tx)) + } +} + #[derive(Debug, Parser, Clone, PartialEq)] pub struct BumpFeeCommand { /// TXID of the transaction to update. diff --git a/src/main.rs b/src/main.rs index d3cac7f..527a3d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -185,6 +185,13 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; } CliSubCommand::Completions { shell: _ } => unimplemented!(), + + #[cfg(feature = "silent-payments")] + CliSubCommand::SilentPaymentCode(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + + cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + } } Ok(()) diff --git a/src/utils/common.rs b/src/utils/common.rs index 43ff5d8..d696210 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -3,6 +3,8 @@ use crate::{commands::WalletOpts, config::WalletConfig, error::BDKCliError as Er use bdk_bip322::SignatureFormat; #[cfg(feature = "cbf")] use bdk_kyoto::{Info, Receiver, UnboundedReceiver, Warning}; +#[cfg(feature = "silent-payments")] +use bdk_sp::encoding::SilentPaymentCode; #[cfg(any( feature = "electrum", feature = "esplora", @@ -11,8 +13,6 @@ use bdk_kyoto::{Info, Receiver, UnboundedReceiver, Warning}; ))] use bdk_wallet::bitcoin::Psbt; use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf}; -#[cfg(feature = "silent-payments")] -use bdk_sp::encoding::SilentPaymentCode; use crate::commands::OfflineWalletSubCommand; use std::{ @@ -241,5 +241,7 @@ pub fn command_requires_db(command: &OfflineWalletSubCommand) -> bool { #[cfg(feature = "bip322")] OfflineWalletSubCommand::VerifyMessage(_) => false, + #[cfg(feature = "silent-payments")] + OfflineWalletSubCommand::CreateSpTx(_) => false, } } diff --git a/tests/integration.rs b/tests/integration.rs index 5e35181..79c887f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -244,5 +244,4 @@ mod test { .unwrap(); assert_eq!(confirmed_balance, 1000000000u64); } - }