diff --git a/Cargo.lock b/Cargo.lock index 4ff8e29..1ab2363 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,7 @@ dependencies = [ "bdk_esplora", "bdk_kyoto", "bdk_redb", + "bdk_sp", "bdk_wallet", "clap", "cli-table", @@ -281,6 +282,14 @@ dependencies = [ "thiserror", ] +[[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 d5767f3..89c3e18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ bdk_redb = { version = "0.1.0", optional = true } shlex = { version = "1.3.0", optional = true } tracing = "0.1.41" tracing-subscriber = "0.3.20" +bdk_sp = { version = "0.1.0", optional = true, git = "https://github.com/bitcoindevkit/bdk-sp", tag = "v0.1.0" } [features] default = ["repl", "sqlite"] @@ -54,3 +55,6 @@ verify = [] # Extra utility tools # Compile policies compiler = [] + +# Experimental silent payment sending capabilities +sp = ["dep:bdk_sp"] diff --git a/README.md b/README.md index bca4e00..bbbba34 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,27 @@ To generate a new extended master key, suitable for use in a descriptor: cargo run -- key generate ``` +#### 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 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 : +``` + ## Justfile We have added the `just` command runner to help you with common commands (during development) and running regtest `bitcoind` if you are using the `rpc` feature. diff --git a/src/commands.rs b/src/commands.rs index 54cddf0..02add82 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -14,6 +14,9 @@ #![allow(clippy::large_enum_variant)] +#[cfg(feature = "sp")] +use {crate::utils::parse_sp_code_value_pairs, bdk_sp::encoding::SilentPaymentCode}; + use bdk_wallet::bitcoin::{ Address, Network, OutPoint, ScriptBuf, bip32::{DerivationPath, Xpriv}, @@ -107,6 +110,19 @@ pub enum CliSubCommand { #[command(flatten)] wallet_opts: WalletOpts, }, + /// 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 = "sp")] + 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. @@ -311,6 +327,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 = "sp")] + 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 5f548d9..8c54363 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,9 @@ pub enum BDKCliError { #[error("Create transaction error: {0}")] CreateTx(#[from] bdk_wallet::error::CreateTxError), + #[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 d9d2cbe..b559eff 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -54,6 +54,16 @@ use std::io::Write; use std::str::FromStr; #[cfg(any(feature = "redb", feature = "compiler"))] use std::sync::Arc; +#[cfg(feature = "sp")] +use { + bdk_sp::{ + bitcoin::{PrivateKey, PublicKey, ScriptBuf}, + encoding::SilentPaymentCode, + send::psbt::derive_sp, + }, + bdk_wallet::keys::{DescriptorPublicKey, DescriptorSecretKey, SinglePubKey}, + std::collections::HashMap, +}; #[cfg(feature = "electrum")] use crate::utils::BlockchainClient::Electrum; @@ -323,7 +333,160 @@ pub fn handle_offline_wallet_subcommand( )?) } } + #[cfg(feature = "sp")] + 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(); + + 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 { + if send_all { + tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); + } else { + 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 { + if 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 _signed = wallet.sign(&mut psbt, SignOptions::default())?; + + 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, @@ -364,7 +527,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 { @@ -372,10 +537,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); } @@ -878,6 +1049,30 @@ pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> { Ok(()) } +#[cfg(feature = "sp")] +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`]. @@ -1177,6 +1372,14 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { let result = handle_key_subcommand(network, key_subcommand, pretty)?; Ok(result) } + #[cfg(feature = "sp")] + CliSubCommand::SilentPaymentCode { + scan, + spend + } => { + 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 cb81074..c91707b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -21,6 +21,8 @@ use bdk_kyoto::{ BuilderExt, Info, LightClient, Receiver, ScanType::Sync, UnboundedReceiver, Warning, builder::Builder, }; +#[cfg(feature = "sp")] +use bdk_sp::encoding::SilentPaymentCode; use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf}; #[cfg(any( @@ -49,6 +51,27 @@ pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { Ok((addr.script_pubkey(), val)) } +#[cfg(feature = "sp")] +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> {