pub extern crate bdk;
#[macro_use]
extern crate serde_json;
#[cfg(any(target_arch = "wasm32", feature = "async-interface"))]
#[macro_use]
extern crate async_trait;
#[macro_use]
extern crate bdk_macros;
use std::collections::BTreeMap;
use std::str::FromStr;
use structopt::StructOpt;
use bdk::bitcoin::consensus::encode::{deserialize, serialize, serialize_hex};
use bdk::bitcoin::hashes::hex::FromHex;
use bdk::bitcoin::util::psbt::PartiallySignedTransaction;
use bdk::bitcoin::{Address, Network, OutPoint, Script, Txid};
use bdk::blockchain::log_progress;
use bdk::Error;
use bdk::{FeeRate, KeychainKind, TxBuilder, Wallet};
#[derive(Debug, StructOpt, Clone, PartialEq)]
#[structopt(name = "BDK CLI",
version = option_env ! ("CARGO_PKG_VERSION").unwrap_or("unknown"),
author = option_env ! ("CARGO_PKG_AUTHORS").unwrap_or(""))]
pub struct WalletOpt {
#[structopt(
name = "NETWORK",
short = "n",
long = "network",
default_value = "testnet"
)]
pub network: Network,
#[structopt(
name = "WALLET_NAME",
short = "w",
long = "wallet",
default_value = "main"
)]
pub wallet: String,
#[cfg(feature = "electrum")]
#[structopt(name = "PROXY_SERVER:PORT", short = "p", long = "proxy")]
pub proxy: Option<String>,
#[structopt(name = "DESCRIPTOR", short = "d", long = "descriptor", required = true)]
pub descriptor: String,
#[structopt(name = "CHANGE_DESCRIPTOR", short = "c", long = "change_descriptor")]
pub change_descriptor: Option<String>,
#[cfg(feature = "esplora")]
#[structopt(name = "ESPLORA_URL", short = "e", long = "esplora")]
pub esplora: Option<String>,
#[cfg(feature = "esplora")]
#[structopt(
name = "ESPLORA_CONCURRENCY",
long = "esplora_concurrency",
default_value = "4"
)]
pub esplora_concurrency: u8,
#[cfg(feature = "electrum")]
#[structopt(
name = "SERVER:PORT",
short = "s",
long = "server",
default_value = "ssl://electrum.blockstream.info:60002"
)]
pub electrum: String,
#[structopt(subcommand)]
pub subcommand: WalletSubCommand,
}
#[derive(Debug, StructOpt, Clone, PartialEq)]
#[structopt(
rename_all = "snake",
long_about = "A modern, lightweight, descriptor-based wallet"
)]
pub enum WalletSubCommand {
GetNewAddress,
Sync {
#[structopt(short = "v", long = "max_addresses")]
max_addresses: Option<u32>,
},
ListUnspent,
ListTransactions,
GetBalance,
CreateTx {
#[structopt(name = "ADDRESS:SAT", long = "to", required = true, parse(try_from_str = parse_recipient))]
recipients: Vec<(Script, u64)>,
#[structopt(short = "all", long = "send_all")]
send_all: bool,
#[structopt(short = "rbf", long = "enable_rbf")]
enable_rbf: bool,
#[structopt(long = "offline_signer")]
offline_signer: bool,
#[structopt(name = "MUST_SPEND_TXID:VOUT", long = "utxos", parse(try_from_str = parse_outpoint))]
utxos: Option<Vec<OutPoint>>,
#[structopt(name = "CANT_SPEND_TXID:VOUT", long = "unspendable", parse(try_from_str = parse_outpoint))]
unspendable: Option<Vec<OutPoint>>,
#[structopt(name = "SATS_VBYTE", short = "fee", long = "fee_rate")]
fee_rate: Option<f32>,
#[structopt(name = "EXT_POLICY", long = "external_policy")]
external_policy: Option<String>,
#[structopt(name = "INT_POLICY", long = "internal_policy")]
internal_policy: Option<String>,
},
BumpFee {
#[structopt(name = "TXID", short = "txid", long = "txid")]
txid: String,
#[structopt(short = "all", long = "send_all")]
send_all: bool,
#[structopt(long = "offline_signer")]
offline_signer: bool,
#[structopt(name = "MUST_SPEND_TXID:VOUT", long = "utxos", parse(try_from_str = parse_outpoint))]
utxos: Option<Vec<OutPoint>>,
#[structopt(name = "CANT_SPEND_TXID:VOUT", long = "unspendable", parse(try_from_str = parse_outpoint))]
unspendable: Option<Vec<OutPoint>>,
#[structopt(name = "SATS_VBYTE", short = "fee", long = "fee_rate")]
fee_rate: f32,
},
Policies,
PublicDescriptor,
Sign {
#[structopt(name = "BASE64_PSBT", long = "psbt")]
psbt: String,
#[structopt(name = "HEIGHT", long = "assume_height")]
assume_height: Option<u32>,
},
Broadcast {
#[structopt(
name = "BASE64_PSBT",
long = "psbt",
required_unless = "RAWTX",
conflicts_with = "RAWTX"
)]
psbt: Option<String>,
#[structopt(
name = "RAWTX",
long = "tx",
required_unless = "BASE64_PSBT",
conflicts_with = "BASE64_PSBT"
)]
tx: Option<String>,
},
ExtractPsbt {
#[structopt(name = "BASE64_PSBT", long = "psbt")]
psbt: String,
},
FinalizePsbt {
#[structopt(name = "BASE64_PSBT", long = "psbt")]
psbt: String,
#[structopt(name = "HEIGHT", long = "assume_height")]
assume_height: Option<u32>,
},
CombinePsbt {
#[structopt(name = "BASE64_PSBT", long = "psbt", required = true)]
psbt: Vec<String>,
},
Repl,
}
fn parse_recipient(s: &str) -> Result<(Script, u64), String> {
let parts: Vec<_> = s.split(':').collect();
if parts.len() != 2 {
return Err("Invalid format".to_string());
}
let addr = Address::from_str(&parts[0]);
if let Err(e) = addr {
return Err(format!("{:?}", e));
}
let val = u64::from_str(&parts[1]);
if let Err(e) = val {
return Err(format!("{:?}", e));
}
Ok((addr.unwrap().script_pubkey(), val.unwrap()))
}
fn parse_outpoint(s: &str) -> Result<OutPoint, String> {
OutPoint::from_str(s).map_err(|e| format!("{:?}", e))
}
#[maybe_async]
pub fn handle_wallet_subcommand<C, D>(
wallet: &Wallet<C, D>,
wallet_subcommand: WalletSubCommand,
) -> Result<serde_json::Value, Error>
where
C: bdk::blockchain::Blockchain,
D: bdk::database::BatchDatabase,
{
match wallet_subcommand {
WalletSubCommand::GetNewAddress => Ok(json!({"address": wallet.get_new_address()?})),
WalletSubCommand::Sync { max_addresses } => {
maybe_await!(wallet.sync(log_progress(), max_addresses))?;
Ok(json!({}))
}
WalletSubCommand::ListUnspent => Ok(serde_json::to_value(&wallet.list_unspent()?)?),
WalletSubCommand::ListTransactions => {
Ok(serde_json::to_value(&wallet.list_transactions(false)?)?)
}
WalletSubCommand::GetBalance => Ok(json!({"satoshi": wallet.get_balance()?})),
WalletSubCommand::CreateTx {
recipients,
send_all,
enable_rbf,
offline_signer,
utxos,
unspendable,
fee_rate,
external_policy,
internal_policy,
} => {
let mut tx_builder = TxBuilder::new();
if send_all {
tx_builder = tx_builder
.drain_wallet()
.set_single_recipient(recipients[0].0.clone());
} else {
tx_builder = tx_builder.set_recipients(recipients);
}
if enable_rbf {
tx_builder = tx_builder.enable_rbf();
}
if offline_signer {
tx_builder = tx_builder
.force_non_witness_utxo()
.include_output_redeem_witness_script();
}
if let Some(fee_rate) = fee_rate {
tx_builder = tx_builder.fee_rate(FeeRate::from_sat_per_vb(fee_rate));
}
if let Some(utxos) = utxos {
tx_builder = tx_builder.utxos(utxos).manually_selected_only();
}
if let Some(unspendable) = unspendable {
tx_builder = tx_builder.unspendable(unspendable);
}
let policies = vec![
external_policy.map(|p| (p, KeychainKind::External)),
internal_policy.map(|p| (p, KeychainKind::Internal)),
];
for (policy, keychain) in policies.into_iter().filter_map(|x| x) {
let policy = serde_json::from_str::<BTreeMap<String, Vec<usize>>>(&policy)
.map_err(|s| Error::Generic(s.to_string()))?;
tx_builder = tx_builder.policy_path(policy, keychain);
}
let (psbt, details) = wallet.create_tx(tx_builder)?;
Ok(json!({"psbt": base64::encode(&serialize(&psbt)),"details": details,}))
}
WalletSubCommand::BumpFee {
txid,
send_all,
offline_signer,
utxos,
unspendable,
fee_rate,
} => {
let txid = Txid::from_str(txid.as_str()).map_err(|s| Error::Generic(s.to_string()))?;
let mut tx_builder = TxBuilder::new().fee_rate(FeeRate::from_sat_per_vb(fee_rate));
if send_all {
tx_builder = tx_builder.maintain_single_recipient();
}
if offline_signer {
tx_builder = tx_builder
.force_non_witness_utxo()
.include_output_redeem_witness_script();
}
if let Some(utxos) = utxos {
tx_builder = tx_builder.utxos(utxos);
}
if let Some(unspendable) = unspendable {
tx_builder = tx_builder.unspendable(unspendable);
}
let (psbt, details) = wallet.bump_fee(&txid, tx_builder)?;
Ok(json!({"psbt": base64::encode(&serialize(&psbt)),"details": details,}))
}
WalletSubCommand::Policies => Ok(json!({
"external": wallet.policies(KeychainKind::External)?,
"internal": wallet.policies(KeychainKind::Internal)?,
})),
WalletSubCommand::PublicDescriptor => Ok(json!({
"external": wallet.public_descriptor(KeychainKind::External)?.map(|d| d.to_string()),
"internal": wallet.public_descriptor(KeychainKind::Internal)?.map(|d| d.to_string()),
})),
WalletSubCommand::Sign {
psbt,
assume_height,
} => {
let psbt = base64::decode(&psbt).unwrap();
let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap();
let (psbt, finalized) = wallet.sign(psbt, assume_height)?;
Ok(json!({"psbt": base64::encode(&serialize(&psbt)),"is_finalized": finalized,}))
}
WalletSubCommand::Broadcast { psbt, tx } => {
let tx = match (psbt, tx) {
(Some(psbt), None) => {
let psbt = base64::decode(&psbt).unwrap();
let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap();
psbt.extract_tx()
}
(None, Some(tx)) => deserialize(&Vec::<u8>::from_hex(&tx).unwrap()).unwrap(),
(Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"),
(None, None) => panic!("Missing `psbt` and `tx` option"),
};
let txid = maybe_await!(wallet.broadcast(tx))?;
Ok(json!({ "txid": txid }))
}
WalletSubCommand::ExtractPsbt { psbt } => {
let psbt = base64::decode(&psbt).unwrap();
let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap();
Ok(json!({"raw_tx": serialize_hex(&psbt.extract_tx()),}))
}
WalletSubCommand::FinalizePsbt {
psbt,
assume_height,
} => {
let psbt = base64::decode(&psbt).unwrap();
let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap();
let (psbt, finalized) = wallet.finalize_psbt(psbt, assume_height)?;
Ok(json!({ "psbt": base64::encode(&serialize(&psbt)),"is_finalized": finalized,}))
}
WalletSubCommand::CombinePsbt { psbt } => {
let mut psbts = psbt
.iter()
.map(|s| {
let psbt = base64::decode(&s).unwrap();
let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap();
psbt
})
.collect::<Vec<_>>();
let init_psbt = psbts.pop().unwrap();
let final_psbt = psbts
.into_iter()
.try_fold::<_, _, Result<PartiallySignedTransaction, Error>>(
init_psbt,
|mut acc, x| {
acc.merge(x)?;
Ok(acc)
},
)?;
Ok(json!({ "psbt": base64::encode(&serialize(&final_psbt)) }))
}
WalletSubCommand::Repl => Ok(json!({})),
}
}
#[cfg(test)]
mod test {
use super::{WalletOpt, WalletSubCommand};
use bdk::bitcoin::{Address, Network, OutPoint};
use std::str::FromStr;
use structopt::StructOpt;
#[test]
fn test_get_new_address() {
let cli_args = vec!["bdk-cli", "--network", "bitcoin",
"--descriptor", "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)",
"--change_descriptor", "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)",
"--esplora", "https://blockstream.info/api/",
"--esplora_concurrency", "5",
"get_new_address"];
let wallet_opt = WalletOpt::from_iter(&cli_args);
let expected_wallet_opt = WalletOpt {
network: Network::Bitcoin,
wallet: "main".to_string(),
proxy: None,
descriptor: "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(),
change_descriptor: Some("wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".to_string()),
#[cfg(feature = "esplora")]
esplora: Some("https://blockstream.info/api/".to_string()),
#[cfg(feature = "esplora")]
esplora_concurrency: 5,
electrum: "ssl://electrum.blockstream.info:60002".to_string(),
subcommand: WalletSubCommand::GetNewAddress,
};
assert_eq!(expected_wallet_opt, wallet_opt);
}
#[test]
fn test_sync() {
let cli_args = vec!["bdk-cli", "--network", "testnet",
"--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)",
"sync", "--max_addresses", "50"];
let wallet_opt = WalletOpt::from_iter(&cli_args);
let expected_wallet_opt = WalletOpt {
network: Network::Testnet,
wallet: "main".to_string(),
proxy: None,
descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(),
change_descriptor: None,
#[cfg(feature = "esplora")]
esplora: None,
#[cfg(feature = "esplora")]
esplora_concurrency: 4,
electrum: "ssl://electrum.blockstream.info:60002".to_string(),
subcommand: WalletSubCommand::Sync {
max_addresses: Some(50)
},
};
assert_eq!(expected_wallet_opt, wallet_opt);
}
#[test]
fn test_create_tx() {
let cli_args = vec!["bdk-cli", "--network", "testnet", "--proxy", "127.0.0.1:9150",
"--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)",
"--change_descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)",
"--server","ssl://electrum.blockstream.info:50002",
"create_tx", "--to", "n2Z3YNXtceeJhFkTknVaNjT1mnCGWesykJ:123456","mjDZ34icH4V2k9GmC8niCrhzVuR3z8Mgkf:78910",
"--utxos","87345e46bfd702d24d54890cc094d08a005f773b27c8f965dfe0eb1e23eef88e:1",
"--utxos","87345e46bfd702d24d54890cc094d08a005f773b27c8f965dfe0eb1e23eef88e:2"];
let wallet_opt = WalletOpt::from_iter(&cli_args);
let script1 = Address::from_str("n2Z3YNXtceeJhFkTknVaNjT1mnCGWesykJ")
.unwrap()
.script_pubkey();
let script2 = Address::from_str("mjDZ34icH4V2k9GmC8niCrhzVuR3z8Mgkf")
.unwrap()
.script_pubkey();
let outpoint1 = OutPoint::from_str(
"87345e46bfd702d24d54890cc094d08a005f773b27c8f965dfe0eb1e23eef88e:1",
)
.unwrap();
let outpoint2 = OutPoint::from_str(
"87345e46bfd702d24d54890cc094d08a005f773b27c8f965dfe0eb1e23eef88e:2",
)
.unwrap();
let expected_wallet_opt = WalletOpt {
network: Network::Testnet,
wallet: "main".to_string(),
proxy: Some("127.0.0.1:9150".to_string()),
descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(),
change_descriptor: Some("wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".to_string()),
#[cfg(feature = "esplora")]
esplora: None,
#[cfg(feature = "esplora")]
esplora_concurrency: 4,
electrum: "ssl://electrum.blockstream.info:50002".to_string(),
subcommand: WalletSubCommand::CreateTx {
recipients: vec![(script1, 123456), (script2, 78910)],
send_all: false,
enable_rbf: false,
offline_signer: false,
utxos: Some(vec!(outpoint1, outpoint2)),
unspendable: None,
fee_rate: None,
external_policy: None,
internal_policy: None,
},
};
assert_eq!(expected_wallet_opt, wallet_opt);
}
#[test]
fn test_broadcast() {
let cli_args = vec!["bdk-cli", "--network", "testnet",
"--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)",
"broadcast",
"--psbt", "cHNidP8BAEICAAAAASWhGE1AhvtO+2GjJHopssFmgfbq+WweHd8zN/DeaqmDAAAAAAD/////AQAAAAAAAAAABmoEAAECAwAAAAAAAAA="];
let wallet_opt = WalletOpt::from_iter(&cli_args);
let expected_wallet_opt = WalletOpt {
network: Network::Testnet,
wallet: "main".to_string(),
proxy: None,
descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(),
change_descriptor: None,
#[cfg(feature = "esplora")]
esplora: None,
#[cfg(feature = "esplora")]
esplora_concurrency: 4,
electrum: "ssl://electrum.blockstream.info:60002".to_string(),
subcommand: WalletSubCommand::Broadcast {
psbt: Some("cHNidP8BAEICAAAAASWhGE1AhvtO+2GjJHopssFmgfbq+WweHd8zN/DeaqmDAAAAAAD/////AQAAAAAAAAAABmoEAAECAwAAAAAAAAA=".to_string()),
tx: None
},
};
assert_eq!(expected_wallet_opt, wallet_opt);
}
#[test]
fn test_wrong_network() {
let cli_args = vec!["repl", "--network", "badnet",
"--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)",
"sync", "--max_addresses", "50"];
let wallet_opt = WalletOpt::from_iter_safe(&cli_args);
assert!(wallet_opt.is_err());
}
}