Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details.
- Fixed `warn!` messages, including the mainnet notice, being hidden unless `RUST_LOG` was set; warnings are now shown by default
- Added `hwi` feature with a hardware wallet `hwi` command to list connected devices, register a wallet policy, display a receive address, and sign PSBTs. Supports Ledger, Coldcard, BitBox02, Jade and Specter.
- Replaced plain `wallets` with `wallets list`, and added `wallets delete <wallet_name>` for unused saved wallet configurations.
- Fixed `create_tx`, `create_sp_tx` and `create_dns_tx` to reject `--to` addresses and `--to-sp` silent payment codes from a different network instead of building an unspendable PSBT
- Removed the redundant `wallet` field from `wallets list` output; the wallet name is still available as the top-level key
- Fixed `compile` silently accepting placeholder keys/hashes instead of validating them; added `--placeholders` to opt back into the old permissive behavior
- Fixed `compile` panicking instead of returning an error when a key is valid on its own but the wrong shape for the requested script type (e.g. an x-only key under `sh`/`wsh`/`sh-wsh`, or an uncompressed key under `tr`)
Expand Down
17 changes: 12 additions & 5 deletions src/handlers/dns/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ use crate::handlers::dns::dns_payment_instructions::{
};
use crate::handlers::{AppContext, AsyncAppCommand, Init, OfflineOperations};
use crate::utils::types::{PsbtResult, StatusResult};
use crate::utils::{parse_dns_recipient, parse_fee_rate, parse_outpoint, parse_recipient};
use crate::utils::{
parse_dns_recipient, parse_fee_rate, parse_outpoint, parse_recipient, recipient_script,
};
use bdk_wallet::KeychainKind;
use bdk_wallet::bitcoin::address::NetworkUnchecked;
use bdk_wallet::bitcoin::base64::Engine;
use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
use bdk_wallet::bitcoin::script::PushBytesBuf;
use bdk_wallet::bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, Sequence};
use bdk_wallet::bitcoin::{Address, Amount, FeeRate, OutPoint, ScriptBuf, Sequence};
use clap::Parser;
use std::collections::BTreeMap;

Expand Down Expand Up @@ -42,7 +45,7 @@ impl AsyncAppCommand<AppContext<Init>> for ResolveDnsRecipientCommand {
#[derive(Parser, Debug, Clone, PartialEq)]
pub struct CreateDnsTxCommand {
#[arg(env = "ADDRESS_SAT", long = "to", value_parser = parse_recipient)]
pub recipients: Vec<(ScriptBuf, u64)>,
pub recipients: Vec<(Address<NetworkUnchecked>, u64)>,
#[arg(long = "to_dns", value_parser = parse_dns_recipient)]
pub dns_recipients: Vec<(String, u64)>,
#[arg(long = "dns_resolver", default_value = "8.8.8.8")]
Expand Down Expand Up @@ -86,8 +89,12 @@ impl AsyncAppCommand<AppContext<OfflineOperations<'_>>> for CreateDnsTxCommand {
&self,
ctx: &mut AppContext<OfflineOperations<'_>>,
) -> Result<Self::Output, Error> {
let network = ctx.network;
let mut recipients: Vec<(ScriptBuf, u64)> = self.recipients.clone();
let network = ctx.state.wallet.network();
let mut recipients: Vec<(ScriptBuf, u64)> = self
.recipients
.iter()
.map(|(address, amount)| Ok((recipient_script(address, network)?, *amount)))
.collect::<Result<_, Error>>()?;

for (hrn, amount_sat) in &self.dns_recipients {
log::info!("Resolving DNS instructions for recipient {hrn}");
Expand Down
50 changes: 37 additions & 13 deletions src/handlers/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ use crate::utils::types::{
AddressResult, BalanceResult, KeychainPair, PsbtResult, RawPsbt, TransactionDetails,
UnspentDetails,
};
use crate::utils::{parse_fee_rate, parse_outpoint, parse_recipient};
use crate::utils::{parse_fee_rate, parse_outpoint, parse_recipient, recipient_script};
use bdk_wallet::bitcoin::address::NetworkUnchecked;
use bdk_wallet::bitcoin::base64::Engine;
use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
use bdk_wallet::bitcoin::script::PushBytesBuf;
use bdk_wallet::bitcoin::{Address, Amount, FeeRate, OutPoint, Psbt, ScriptBuf, Sequence, Txid};
use bdk_wallet::bitcoin::{Address, Amount, FeeRate, OutPoint, Psbt, Sequence, Txid};
use bdk_wallet::{KeychainKind, SignOptions};
use clap::Parser;
use serde_json::json;
Expand All @@ -24,6 +25,7 @@ use {
encoding::SilentPaymentCode,
send::psbt::derive_sp,
},
bdk_wallet::bitcoin::ScriptBuf,
bdk_wallet::bitcoin::key::Secp256k1,
bdk_wallet::keys::{DescriptorPublicKey, DescriptorSecretKey, SinglePubKey},
std::collections::HashMap,
Expand Down Expand Up @@ -194,7 +196,7 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for BalanceCommand {
pub struct CreateTxCommand {
/// Adds a recipient to the transaction.
#[arg(env = "ADDRESS_SAT", long = "to", required = true, value_parser = parse_recipient)]
pub recipients: Vec<(ScriptBuf, u64)>,
pub recipients: Vec<(Address<NetworkUnchecked>, u64)>,

/// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0.
#[arg(long = "send_all", short = 'a')]
Expand Down Expand Up @@ -251,13 +253,14 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateTxCommand {
type Output = PsbtResult;

fn execute(&self, ctx: &mut AppContext<OfflineOperations<'_>>) -> Result<Self::Output, Error> {
let network = ctx.state.wallet.network();
let mut tx_builder = ctx.state.wallet.build_tx();

if self.send_all {
if self.recipients.len() == 1 {
tx_builder
.drain_wallet()
.drain_to(self.recipients[0].0.clone());
.drain_to(recipient_script(&self.recipients[0].0, network)?);
} else {
return Err(Error::Generic(
"Wallet can only be drained to a single output".to_string(),
Expand All @@ -266,10 +269,14 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateTxCommand {
} else {
let recipients = self
.recipients
.clone()
.into_iter()
.map(|(script, amount)| (script, Amount::from_sat(amount)))
.collect();
.iter()
.map(|(address, amount)| {
Ok((
recipient_script(address, network)?,
Amount::from_sat(*amount),
))
})
.collect::<Result<_, Error>>()?;
tx_builder.set_recipients(recipients);
}

Expand Down Expand Up @@ -328,7 +335,7 @@ pub struct CreateSpTxCommand {
// 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<Vec<(ScriptBuf, u64)>>,
pub recipients: Option<Vec<(Address<NetworkUnchecked>, u64)>>,
/// Parse silent payment recipients
#[arg(long = "to-sp", required = true, value_parser = parse_sp_code_value_pairs)]
pub silent_payment_recipients: Vec<(SilentPaymentCode, u64)>,
Expand Down Expand Up @@ -376,13 +383,22 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateSpTxCommand {
type Output = RawPsbt;

fn execute(&self, ctx: &mut AppContext<OfflineOperations<'_>>) -> Result<Self::Output, Error> {
let network = ctx.state.wallet.network();
let mut tx_builder = ctx.state.wallet.build_tx();

let sp_recipients: Vec<SilentPaymentCode> = self
.silent_payment_recipients
.iter()
.map(|(sp_code, _)| sp_code.clone())
.collect();
.map(|(sp_code, _)| {
if sp_code.network != network {
return Err(Error::Generic(format!(
"Silent payment code network mismatch: expected {network}, found {} for {sp_code}",
sp_code.network
)));
}
Ok(sp_code.clone())
})
.collect::<Result<_, Error>>()?;

if self.send_all {
if sp_recipients.len() == 1 && self.recipients.is_none() {
Expand All @@ -393,7 +409,9 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateSpTxCommand {
&& sp_recipients.is_empty()
{
if recipients.len() == 1 {
tx_builder.drain_wallet().drain_to(recipients[0].0.clone());
tx_builder
.drain_wallet()
.drain_to(recipient_script(&recipients[0].0, network)?);
} else {
return Err(Error::Generic(
"Wallet can only be drain to a single output".to_string(),
Expand All @@ -417,7 +435,13 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateSpTxCommand {
if let Some(recipients) = &self.recipients {
let recipients = recipients
.iter()
.map(|(script, amount)| (script.clone(), Amount::from_sat(*amount)));
.map(|(address, amount)| {
Ok((
recipient_script(address, network)?,
Amount::from_sat(*amount),
))
})
.collect::<Result<Vec<_>, Error>>()?;

outputs.extend(recipients);
}
Expand Down
38 changes: 33 additions & 5 deletions src/utils/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use bdk_kyoto::{Info, Receiver, UnboundedReceiver, Warning};
use bdk_message_signer::SignatureFormat;
#[cfg(feature = "silent-payments")]
use bdk_sp::encoding::SilentPaymentCode;
use bdk_wallet::bitcoin::address::NetworkUnchecked;
use bdk_wallet::bitcoin::{Address, FeeRate, Network, OutPoint, ScriptBuf};
#[cfg(any(
feature = "electrum",
Expand Down Expand Up @@ -64,17 +65,44 @@ pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> {
}

/// Parse the recipient (Address,Amount) argument from cli input.
pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> {
pub(crate) fn parse_recipient(s: &str) -> Result<(Address<NetworkUnchecked>, 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])
.map_err(|e| e.to_string())?
.assume_checked();
let addr = Address::from_str(parts[0]).map_err(|e| e.to_string())?;
let val = u64::from_str(parts[1]).map_err(|e| e.to_string())?;

Ok((addr.script_pubkey(), val))
Ok((addr, val))
}

/// Return the network an unchecked address belongs to.
fn address_network(address: &Address<NetworkUnchecked>) -> Result<Network, Error> {
[
Network::Bitcoin,
Network::Testnet,
Network::Testnet4,
Network::Signet,
Network::Regtest,
]
.into_iter()
.find(|network| address.is_valid_for_network(*network))
.ok_or(Error::Generic(format!("Invalid address: {address:?}")))
}

/// Convert a recipient address to a script pubkey, requiring the wallet's network.
pub(crate) fn recipient_script(
address: &Address<NetworkUnchecked>,
network: Network,
) -> Result<ScriptBuf, Error> {
match address.clone().require_network(network) {
Ok(checked) => Ok(checked.script_pubkey()),
Err(_) => Err(Error::Generic(format!(
"Network mismatch: expected {network}, found {} for {}",
address_network(address)?,
address.clone().assume_checked()
))),
}
}

#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))]
Expand Down
129 changes: 118 additions & 11 deletions tests/integration/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ mod test_offline {

static WALLET_NAME: &str = "test_config_wallet";
/// A `--to` argument, the wallet is unfunded, so nothing is spent.
static RECIPIENT: &str = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:10000";
static RECIPIENT: &str = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080:10000";

/// Helper to spin up a sandboxed CLI with the generated descriptors
fn setup_wallet_config() -> (BdkCli, Command) {
Expand Down Expand Up @@ -116,17 +116,18 @@ mod test_offline {
let (cli, mut cmd_init) = setup_wallet_config();
cmd_init.assert().success();

let address = cli
.wallet_cmd(&["--wallet", WALLET_NAME, "new_address"])
.output()
.unwrap();
let address_json: Value = serde_json::from_slice(&address.stdout).unwrap();
let recipient = format!("{}:1000", address_json["address"].as_str().unwrap());

// create transaction
cli.wallet_cmd(&[
"--wallet",
WALLET_NAME,
"create_tx",
"--to",
"tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:1000",
])
.assert()
.failure()
.stderr(predicate::str::contains("Insufficient"));
cli.wallet_cmd(&["--wallet", WALLET_NAME, "create_tx", "--to", &recipient])
.assert()
.failure()
.stderr(predicate::str::contains("Insufficient"));
}

#[test]
Expand Down Expand Up @@ -321,6 +322,112 @@ mod test_offline {
.stdout(predicate::str::contains("\"valid\": false"));
}

#[test]
fn test_create_tx_rejects_address_from_wrong_network() {
let (cli, mut cmd_init) = setup_wallet_config();
cmd_init.assert().success();

// Wallet is regtest, recipient is testnet.
let recipient = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:1000";
cli.wallet_cmd(&["--wallet", WALLET_NAME, "create_tx", "--to", recipient])
.assert()
.failure()
.stderr(predicate::str::contains(
"Network mismatch: expected regtest, found testnet",
));
}

#[cfg(feature = "silent-payments")]
#[test]
fn test_create_sp_tx_rejects_address_from_wrong_network() {
let (cli, mut cmd_init) = setup_wallet_config();
cmd_init.assert().success();

let sp_code = cli
.cmd(
"silent_payment_code",
&[
"--scan_key",
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"--spend_key",
"02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
],
)
.output()
.unwrap();
let sp_code: Value = serde_json::from_slice(&sp_code.stdout).unwrap();
let sp_recipient = format!("{}:1000", sp_code["message"].as_str().unwrap());

// Wallet is regtest, recipient is testnet.
let recipient = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:1000";
cli.wallet_cmd(&[
"--wallet",
WALLET_NAME,
"create_sp_tx",
"--to",
recipient,
"--to-sp",
&sp_recipient,
])
.assert()
.failure()
.stderr(predicate::str::contains(
"Network mismatch: expected regtest, found testnet",
));
}

#[cfg(feature = "silent-payments")]
#[test]
fn test_create_sp_tx_rejects_code_from_wrong_network() {
let (cli, mut cmd_init) = setup_wallet_config();
cmd_init.assert().success();

// Wallet is regtest, silent payment code is testnet.
let sp_code = BdkCli::new("testnet", None)
.cmd(
"silent_payment_code",
&[
"--scan_key",
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"--spend_key",
"02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
],
)
.output()
.unwrap();
let sp_code: Value = serde_json::from_slice(&sp_code.stdout).unwrap();
let sp_recipient = format!("{}:1000", sp_code["message"].as_str().unwrap());

cli.wallet_cmd(&[
"--wallet",
WALLET_NAME,
"create_sp_tx",
"--to-sp",
&sp_recipient,
])
.assert()
.failure()
.stderr(predicate::str::contains(
"Silent payment code network mismatch: expected regtest, found testnet",
));
}

#[cfg(feature = "dns_payment")]
#[test]
fn test_create_dns_tx_rejects_address_from_wrong_network() {
let (cli, mut cmd_init) = setup_wallet_config();
cmd_init.assert().success();

// Wallet is regtest, recipient is testnet.
let recipient = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:1000";
cli.wallet_cmd(&["--wallet", WALLET_NAME, "create_dns_tx", "--to", recipient])
.assert()
.failure()
.stderr(predicate::str::contains(
"Network mismatch: expected regtest, found testnet",
));
}

#[test]
fn test_create_tx_send_all_rejects_multiple_recipients() {
let (cli, mut cmd_init) = setup_wallet_config();
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/online.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ mod test_online {
use tempfile::TempDir;

static WALLET_NAME: &str = "test_online_wallet";
static RECIPIENT: &str = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4";
static RECIPIENT: &str = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080";

/// Spins up the envt, a Regtest node + electrs, and saves the wallet config.
fn setup_online_wallet() -> (BdkCli, Command, TestEnv) {
Expand Down