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
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ shlex = { version = "1.3.0", optional = true }
payjoin = { version = "=1.0.0-rc.1", features = ["v1", "v2", "io", "_test-utils"], optional = true}
reqwest = { version = "0.13.2", default-features = false, optional = true }
url = { version = "2.5.8", optional = true }
bip329 = "0.4.0"

[features]
default = ["repl", "sqlite"]
Expand Down
18 changes: 17 additions & 1 deletion src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

#![allow(clippy::large_enum_variant)]
use bdk_wallet::bitcoin::{
Address, Network, OutPoint, ScriptBuf,
Address, Network, OutPoint, ScriptBuf, Txid,
bip32::{DerivationPath, Xpriv},
};
use clap::{Args, Parser, Subcommand, ValueEnum, value_parser};
Expand Down Expand Up @@ -461,6 +461,22 @@ pub enum OfflineWalletSubCommand {
#[arg(env = "BASE64_PSBT", required = true)]
psbt: Vec<String>,
},

/// Set a human-readable label for a wallet item (address, transaction, or UTXO).
Label {
/// The human-readable label string.
#[arg(env = "LABEL_STR", required = true)]
label_str: String,
/// The address to label.
#[arg(long, conflicts_with_all = ["txid", "utxo"], required_unless_present_any = ["txid", "utxo"], value_parser = crate::utils::parse_address)]
address: Option<Address>,
/// The transaction ID to label.
#[arg(long, conflicts_with_all = ["address", "utxo"], required_unless_present_any = ["address", "utxo"], value_parser = crate::utils::parse_txid)]
txid: Option<Txid>,
/// The UTXO (outpoint) to label.
#[arg(long, conflicts_with_all = ["address", "txid"], required_unless_present_any = ["address", "txid"], value_parser = crate::utils::parse_outpoint)]
utxo: Option<OutPoint>,
},
}

/// Wallet subcommands that needs a blockchain backend.
Expand Down
129 changes: 124 additions & 5 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,40 @@ use {
bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint},
};

use bip329::LabelRef;
use bip329::{AddressRecord, Label as BipLabel, OutputRecord, TransactionRecord};
use std::ops::DerefMut;

/// Helper to format BIP-329 LabelRefs into searchable strings
fn format_ref_str(item_ref: &LabelRef) -> String {
match item_ref {
LabelRef::Txid(txid) => format!("txid:{txid}"),
LabelRef::Address(addr) => format!("addr:{}", addr.assume_checked_ref()),
LabelRef::Output(op) => format!("output:{op}"),
LabelRef::Input(op) => format!("input:{op}"),
_ => item_ref.to_string(), // Fallback for pubkey/xpub
}
}

/// Helper to safely extract a label string or return an em dash
fn get_label_string(labels: &bip329::Labels, target_ref_str: &str) -> String {
labels
.iter()
.find(|l| format_ref_str(&l.ref_()) == target_ref_str)
.and_then(|l| l.label().map(|s| s.to_string()))
.unwrap_or_else(|| "—".to_string())
}

fn add_or_update_label(labels: &mut bip329::Labels, new_label: BipLabel) {
let target_ref = new_label.ref_();
let labels_vec = labels.deref_mut();

match labels_vec.iter_mut().find(|l| l.ref_() == target_ref) {
Some(existing) => *existing = new_label,
None => labels_vec.push(new_label),
}
}

#[cfg(feature = "compiler")]
const NUMS_UNSPENDABLE_KEY_HEX: &str =
"50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0";
Expand All @@ -103,6 +137,7 @@ pub fn handle_offline_wallet_subcommand(
wallet_opts: &WalletOpts,
cli_opts: &CliOpts,
offline_subcommand: OfflineWalletSubCommand,
labels: &mut bip329::Labels,
) -> Result<String, Error> {
match offline_subcommand {
NewAddress => {
Expand Down Expand Up @@ -156,6 +191,46 @@ pub fn handle_offline_wallet_subcommand(
}))?)
}
}
Label {
label_str,
address,
txid,
utxo,
} => {
let new_label = if let Some(addr) = address {
BipLabel::Address(AddressRecord {
ref_: addr.into_unchecked(),
label: Some(label_str.clone()),
})
} else if let Some(tx) = txid {
BipLabel::Transaction(TransactionRecord {
ref_: tx,
label: Some(label_str.clone()),
origin: None,
})
} else if let Some(outpoint) = utxo {
BipLabel::Output(OutputRecord {
ref_: outpoint,
label: Some(label_str.clone()),
spendable: false,
})
} else {
return Err(Error::Generic(
"No target provided for the label".to_string(),
));
};

add_or_update_label(labels, new_label);

if cli_opts.pretty {
Ok(format!("Successfully applied label '{label_str}'"))
} else {
Ok(serde_json::to_string_pretty(&json!({
"message": "Label successfully applied",
"label": label_str
}))?)
}
}
Unspent => {
let utxos = wallet.list_unspent().collect::<Vec<_>>();
if cli_opts.pretty {
Expand All @@ -172,6 +247,8 @@ pub fn handle_offline_wallet_subcommand(
ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(),
};

let label_str = get_label_string(labels, &format!("output:{}", utxo.outpoint));

rows.push(vec![
shorten(utxo.outpoint, 8, 10).cell(),
utxo.txout
Expand All @@ -188,6 +265,7 @@ pub fn handle_offline_wallet_subcommand(
utxo.derivation_index.cell(),
height.to_string().cell().justify(Justify::Right),
shorten(block_hash, 8, 8).cell().justify(Justify::Right),
label_str.cell(),
]);
}
let table = rows
Expand All @@ -201,12 +279,24 @@ pub fn handle_offline_wallet_subcommand(
"Index".cell().bold(true),
"Block Height".cell().bold(true),
"Block Hash".cell().bold(true),
"Label".cell().bold(true),
])
.display()
.map_err(|e| Error::Generic(e.to_string()))?;
Ok(format!("{table}"))
} else {
Ok(serde_json::to_string_pretty(&utxos)?)
let utxos_json: Vec<_> = utxos
.iter()
.map(|utxo| {
let mut json_obj = serde_json::to_value(utxo).unwrap();
json_obj["label"] = json!(get_label_string(
labels,
&format!("output:{}", utxo.outpoint)
));
json_obj
})
.collect();
Ok(serde_json::to_string_pretty(&utxos_json)?)
}
}
Transactions => {
Expand All @@ -233,13 +323,15 @@ pub fn handle_offline_wallet_subcommand(
.collect::<Vec<_>>();
let mut rows: Vec<Vec<CellStruct>> = vec![];
for (txid, version, is_rbf, input_count, output_count, total_value) in txns {
let label_str = get_label_string(labels, &format!("txid:{txid}"));
rows.push(vec![
txid.cell(),
version.to_string().cell().justify(Justify::Right),
is_rbf.to_string().cell().justify(Justify::Center),
input_count.to_string().cell().justify(Justify::Right),
output_count.to_string().cell().justify(Justify::Right),
total_value.to_string().cell().justify(Justify::Right),
label_str.cell(),
]);
}
let table = rows
Expand All @@ -251,21 +343,24 @@ pub fn handle_offline_wallet_subcommand(
"Input Count".cell().bold(true),
"Output Count".cell().bold(true),
"Total Value (sat)".cell().bold(true),
"Label".cell().bold(true),
])
.display()
.map_err(|e| Error::Generic(e.to_string()))?;
Ok(format!("{table}"))
} else {
let txns: Vec<_> = transactions
.map(|tx| {
let txid = tx.tx_node.txid.to_string();
json!({
"txid": tx.tx_node.txid,
"txid": txid,
"is_coinbase": tx.tx_node.is_coinbase(),
"wtxid": tx.tx_node.compute_wtxid(),
"version": tx.tx_node.version,
"is_rbf": tx.tx_node.is_explicitly_rbf(),
"inputs": tx.tx_node.input,
"outputs": tx.tx_node.output,
"label": get_label_string(labels, &format!("txid:{txid}")),
})
})
.collect();
Expand Down Expand Up @@ -1272,6 +1367,18 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result<String, Error> {
let datadir = cli_opts.datadir.clone();
let home_dir = prepare_home_dir(datadir)?;
let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?;
let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?;
let label_file_path = database_path.join("labels.jsonl");

let mut labels = match bip329::Labels::try_from_file(&label_file_path) {
Ok(loaded_labels) => loaded_labels,
Err(bip329::error::ParseError::FileReadError(io_err))
if io_err.kind() == std::io::ErrorKind::NotFound =>
{
bip329::Labels::default()
}
Err(e) => return Err(Error::Generic(format!("Failed to load labels: {e}"))),
};

#[cfg(any(feature = "sqlite", feature = "redb"))]
let result = {
Expand Down Expand Up @@ -1302,6 +1409,7 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result<String, Error> {
&wallet_opts,
&cli_opts,
offline_subcommand.clone(),
&mut labels,
)?;
wallet.persist(&mut persister)?;
result
Expand All @@ -1314,8 +1422,13 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result<String, Error> {
&wallet_opts,
&cli_opts,
offline_subcommand.clone(),
&mut labels,
)?
};
labels
.export_to_file(&label_file_path)
.map_err(|e| Error::Generic(format!("Failed to save labels: {e}")))?;

Ok(result)
}
CliSubCommand::Wallet {
Expand Down Expand Up @@ -1472,9 +1585,15 @@ async fn respond(
ReplSubCommand::Wallet {
subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand),
} => {
let value =
handle_offline_wallet_subcommand(wallet, wallet_opts, cli_opts, offline_subcommand)
.map_err(|e| e.to_string())?;
let mut labels = bip329::Labels::default();
let value = handle_offline_wallet_subcommand(
wallet,
wallet_opts,
cli_opts,
offline_subcommand,
&mut labels,
)
.map_err(|e| e.to_string())?;
Some(value)
}
ReplSubCommand::Wallet {
Expand Down
7 changes: 6 additions & 1 deletion src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ use bdk_wallet::{PersistedWallet, WalletPersister};

use bdk_wallet::bip39::{Language, Mnemonic};
use bdk_wallet::bitcoin::{
Address, Network, OutPoint, ScriptBuf, bip32::Xpriv, secp256k1::Secp256k1,
Address, Network, OutPoint, ScriptBuf, Txid, bip32::Xpriv, secp256k1::Secp256k1,
};
use bdk_wallet::descriptor::Segwitv0;
use bdk_wallet::keys::{GeneratableKey, GeneratedKey, bip39::WordCount};
Expand Down Expand Up @@ -84,6 +84,11 @@ pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> {
Ok((user, passwd))
}

/// Parse a txid argument from cli input.
pub(crate) fn parse_txid(s: &str) -> Result<Txid, Error> {
Txid::from_str(s).map_err(|e| Error::Generic(e.to_string()))
}

/// Parse a outpoint (Txid:Vout) argument from cli input.
pub(crate) fn parse_outpoint(s: &str) -> Result<OutPoint, Error> {
Ok(OutPoint::from_str(s)?)
Expand Down