Skip to content

Commit bc40b76

Browse files
committed
feat(silentpayments): add CreateSpTx command for silent payment transactions
Add experimental silent payment sending capabilities with new CreateSpTx command. This command creates signed transactions directly rather than PSBTs due to current limitations in secure shared derivation. - Add bdk_sp dependency with "sp" feature flag - Implement CreateSpTx subcommand for offline wallet operations - Add silent payment recipient parsing utility - Support mixed recipients (regular addresses + silent payments) - Generate signed transactions ready for broadcast - For the moment is not possible to enable RBF for the created transactions. Note: This is experimental functionality for testing only, not recommended for mainnet use.
1 parent d7d38f2 commit bc40b76

File tree

5 files changed

+253
-6
lines changed

5 files changed

+253
-6
lines changed

Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ bdk_redb = { version = "0.1.0", optional = true }
3131
shlex = { version = "1.3.0", optional = true }
3232
tracing = "0.1.41"
3333
tracing-subscriber = "0.3.19"
34+
bdk_sp = { version = "0.1.0", optional = true, git = "https://github.com/bitcoindevkit/bdk-sp", tag = "v0.1.0" }
3435

3536
[features]
3637
default = ["repl", "sqlite"]
@@ -54,3 +55,6 @@ verify = []
5455
# Extra utility tools
5556
# Compile policies
5657
compiler = []
58+
59+
# Experimental silent payment sending capabilities
60+
sp = ["dep:bdk_sp"]

src/commands.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
1515
#![allow(clippy::large_enum_variant)]
1616

17+
#[cfg(feature = "sp")]
18+
use {crate::utils::parse_sp_code_value_pairs, bdk_sp::encoding::SilentPaymentCode};
19+
1720
use bdk_wallet::bitcoin::{
1821
Address, Network, OutPoint, ScriptBuf,
1922
bip32::{DerivationPath, Xpriv},
@@ -315,6 +318,62 @@ pub enum OfflineWalletSubCommand {
315318
)]
316319
add_data: Option<String>, //base 64 econding
317320
},
321+
/// Creates a silent payment transaction
322+
///
323+
/// This sub-command is **EXPERIMENTAL** and should only be used for testing. Do not use this
324+
/// feature to create transactions that spend actual funds on the Bitcoin mainnet.
325+
326+
// This command DOES NOT return a PSBT. Instead, it directly returns a signed transaction
327+
// ready for broadcast, as it is not yet possible to perform a shared derivation of a silent
328+
// payment script pubkey in a secure and trustless manner.
329+
#[cfg(feature = "sp")]
330+
CreateSpTx {
331+
/// Adds a recipient to the transaction.
332+
// Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704.
333+
// Address and amount parsing is done at run time in handler function.
334+
#[arg(env = "ADDRESS:SAT", long = "to", required = false, value_parser = parse_recipient)]
335+
recipients: Option<Vec<(ScriptBuf, u64)>>,
336+
/// Parse silent payment recipients
337+
#[arg(long = "to-sp", required = true, value_parser = parse_sp_code_value_pairs)]
338+
silent_payment_recipients: Vec<(SilentPaymentCode, u64)>,
339+
/// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0.
340+
#[arg(long = "send_all", short = 'a')]
341+
send_all: bool,
342+
/// 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.
343+
#[arg(long = "offline_signer")]
344+
offline_signer: bool,
345+
/// Selects which utxos *must* be spent.
346+
#[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)]
347+
utxos: Option<Vec<OutPoint>>,
348+
/// Marks a utxo as unspendable.
349+
#[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)]
350+
unspendable: Option<Vec<OutPoint>>,
351+
/// Fee rate to use in sat/vbyte.
352+
#[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")]
353+
fee_rate: Option<f32>,
354+
/// Selects which policy should be used to satisfy the external descriptor.
355+
#[arg(env = "EXT_POLICY", long = "external_policy")]
356+
external_policy: Option<String>,
357+
/// Selects which policy should be used to satisfy the internal descriptor.
358+
#[arg(env = "INT_POLICY", long = "internal_policy")]
359+
internal_policy: Option<String>,
360+
/// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes)
361+
#[arg(
362+
env = "ADD_STRING",
363+
long = "add_string",
364+
short = 's',
365+
conflicts_with = "add_data"
366+
)]
367+
add_string: Option<String>,
368+
/// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes)
369+
#[arg(
370+
env = "ADD_DATA",
371+
long = "add_data",
372+
short = 'o',
373+
conflicts_with = "add_string"
374+
)]
375+
add_data: Option<String>, //base 64 econding
376+
},
318377
/// Bumps the fees of an RBF transaction.
319378
BumpFee {
320379
/// TXID of the transaction to update.

src/handlers.rs

Lines changed: 160 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ use bdk_wallet::bitcoin::{
3030
};
3131
use bdk_wallet::chain::ChainPosition;
3232
use bdk_wallet::descriptor::Segwitv0;
33+
use bdk_wallet::keys::{
34+
DerivableKey, DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratableKey, GeneratedKey,
35+
bip39::WordCount,
36+
};
37+
use bdk_wallet::miniscript::miniscript;
3338
#[cfg(feature = "sqlite")]
3439
use bdk_wallet::rusqlite::Connection;
3540
use bdk_wallet::{KeychainKind, SignOptions, Wallet};
@@ -39,12 +44,6 @@ use bdk_wallet::{
3944
miniscript::policy::Concrete,
4045
};
4146
use cli_table::{Cell, CellStruct, Style, Table, format::Justify};
42-
43-
use bdk_wallet::keys::{
44-
DerivableKey, DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratableKey, GeneratedKey,
45-
bip39::WordCount,
46-
};
47-
use bdk_wallet::miniscript::miniscript;
4847
use serde_json::json;
4948
use std::collections::BTreeMap;
5049
#[cfg(any(feature = "electrum", feature = "esplora"))]
@@ -53,6 +52,16 @@ use std::convert::TryFrom;
5352
#[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))]
5453
use std::io::Write;
5554
use std::str::FromStr;
55+
#[cfg(feature = "sp")]
56+
use {
57+
bdk_sp::{
58+
bitcoin::{PrivateKey, PublicKey, ScriptBuf, XOnlyPublicKey},
59+
encoding::SilentPaymentCode,
60+
send::psbt::derive_sp,
61+
},
62+
bdk_wallet::keys::{DescriptorPublicKey, DescriptorSecretKey, SinglePubKey},
63+
std::collections::HashMap,
64+
};
5665

5766
#[cfg(feature = "electrum")]
5867
use crate::utils::BlockchainClient::Electrum;
@@ -318,7 +327,152 @@ pub fn handle_offline_wallet_subcommand(
318327
)?)
319328
}
320329
}
330+
#[cfg(feature = "sp")]
331+
CreateSpTx {
332+
recipients: maybe_recipients,
333+
silent_payment_recipients,
334+
send_all,
335+
offline_signer,
336+
utxos,
337+
unspendable,
338+
fee_rate,
339+
external_policy,
340+
internal_policy,
341+
add_data,
342+
add_string,
343+
} => {
344+
let mut tx_builder = wallet.build_tx();
345+
346+
let sp_recipients: Vec<SilentPaymentCode> = silent_payment_recipients
347+
.iter()
348+
.map(|(sp_code, _)| sp_code.clone())
349+
.collect();
321350

351+
let mut outputs: Vec<(ScriptBuf, Amount)> = silent_payment_recipients
352+
.iter()
353+
.map(|(sp_code, amount)| {
354+
let script = sp_code.get_placeholder_p2tr_spk();
355+
(script, Amount::from_sat(*amount))
356+
})
357+
.collect();
358+
359+
if let Some(recipients) = maybe_recipients {
360+
if send_all {
361+
tx_builder.drain_wallet().drain_to(recipients[0].0.clone());
362+
} else {
363+
let recipients = recipients
364+
.into_iter()
365+
.map(|(script, amount)| (script, Amount::from_sat(amount)));
366+
367+
outputs.extend(recipients);
368+
}
369+
}
370+
371+
tx_builder.set_recipients(outputs);
372+
373+
// Do not enable RBF for this transaction
374+
tx_builder.set_exact_sequence(Sequence::MAX);
375+
376+
if offline_signer {
377+
tx_builder.include_output_redeem_witness_script();
378+
}
379+
380+
if let Some(fee_rate) = fee_rate {
381+
if let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) {
382+
tx_builder.fee_rate(fee_rate);
383+
}
384+
}
385+
386+
if let Some(utxos) = utxos {
387+
tx_builder.add_utxos(&utxos[..]).unwrap();
388+
}
389+
390+
if let Some(unspendable) = unspendable {
391+
tx_builder.unspendable(unspendable);
392+
}
393+
394+
if let Some(base64_data) = add_data {
395+
let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap();
396+
tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap());
397+
} else if let Some(string_data) = add_string {
398+
let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap();
399+
tx_builder.add_data(&data);
400+
}
401+
402+
let policies = vec![
403+
external_policy.map(|p| (p, KeychainKind::External)),
404+
internal_policy.map(|p| (p, KeychainKind::Internal)),
405+
];
406+
407+
for (policy, keychain) in policies.into_iter().flatten() {
408+
let policy = serde_json::from_str::<BTreeMap<String, Vec<usize>>>(&policy)?;
409+
tx_builder.policy_path(policy, keychain);
410+
}
411+
412+
let mut psbt = tx_builder.finish()?;
413+
414+
let unsigned_psbt = psbt.clone();
415+
416+
let _signed = wallet.sign(&mut psbt, SignOptions::default())?;
417+
418+
for (full_input, psbt_input) in unsigned_psbt.inputs.iter().zip(psbt.inputs.iter_mut())
419+
{
420+
// repopulate key derivation data
421+
psbt_input.bip32_derivation = full_input.bip32_derivation.clone();
422+
psbt_input.tap_key_origins = full_input.tap_key_origins.clone();
423+
}
424+
425+
let secp = Secp256k1::new();
426+
let mut external_signers = wallet.get_signers(KeychainKind::External).as_key_map(&secp);
427+
let internal_signers = wallet.get_signers(KeychainKind::Internal).as_key_map(&secp);
428+
external_signers.extend(internal_signers);
429+
430+
match external_signers.iter().next().expect("not empty") {
431+
(DescriptorPublicKey::Single(single_pub), DescriptorSecretKey::Single(prv)) => {
432+
match single_pub.key {
433+
SinglePubKey::FullKey(pk) => {
434+
let keys: HashMap<PublicKey, PrivateKey> = [(pk, prv.key)].into();
435+
derive_sp(&mut psbt, &keys, &sp_recipients, &secp)
436+
.expect("will fix later");
437+
}
438+
SinglePubKey::XOnly(xonly) => {
439+
let keys: HashMap<XOnlyPublicKey, PrivateKey> =
440+
[(xonly, prv.key)].into();
441+
derive_sp(&mut psbt, &keys, &sp_recipients, &secp)
442+
.expect("will fix later");
443+
}
444+
};
445+
}
446+
(_, DescriptorSecretKey::XPrv(k)) => {
447+
derive_sp(&mut psbt, &k.xkey, &sp_recipients, &secp).expect("will fix later");
448+
}
449+
_ => unimplemented!("multi xkey signer"),
450+
};
451+
452+
// Unfinalize PSBT to resign
453+
for psbt_input in psbt.inputs.iter_mut() {
454+
psbt_input.final_script_sig = None;
455+
psbt_input.final_script_witness = None;
456+
}
457+
458+
let _resigned = wallet.sign(&mut psbt, SignOptions::default())?;
459+
460+
let raw_tx = psbt.extract_tx()?;
461+
if cli_opts.pretty {
462+
let table = vec![vec![
463+
"Raw Transaction".cell().bold(true),
464+
serialize_hex(&raw_tx).cell(),
465+
]]
466+
.table()
467+
.display()
468+
.map_err(|e| Error::Generic(e.to_string()))?;
469+
Ok(format!("{table}"))
470+
} else {
471+
Ok(serde_json::to_string_pretty(
472+
&json!({"raw_tx": serialize_hex(&raw_tx)}),
473+
)?)
474+
}
475+
}
322476
CreateTx {
323477
recipients,
324478
send_all,

src/utils.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use bdk_kyoto::{
2323
UnboundedReceiver, Warning,
2424
builder::NodeBuilder,
2525
};
26+
#[cfg(feature = "sp")]
27+
use bdk_sp::encoding::SilentPaymentCode;
2628
use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf};
2729

2830
#[cfg(any(
@@ -51,6 +53,25 @@ pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> {
5153
Ok((addr.script_pubkey(), val))
5254
}
5355

56+
#[cfg(feature = "sp")]
57+
pub(crate) fn parse_sp_code_value_pairs(s: &str) -> Result<(SilentPaymentCode, u64), String> {
58+
let parts: Vec<&str> = s.split(':').collect();
59+
if parts.len() != 2 {
60+
return Err(format!("Invalid format '{}'. Expected 'key:value'", s));
61+
}
62+
63+
let value_0 = parts[0].trim();
64+
let key = SilentPaymentCode::try_from(value_0)
65+
.map_err(|_| format!("Invalid silent payment address: {}", value_0))?;
66+
67+
let value = parts[1]
68+
.trim()
69+
.parse::<u64>()
70+
.map_err(|_| format!("Invalid number '{}' for key '{}'", parts[1], key))?;
71+
72+
Ok((key, value))
73+
}
74+
5475
#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))]
5576
/// Parse the proxy (Socket:Port) argument from the cli input.
5677
pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> {

0 commit comments

Comments
 (0)