diff --git a/Cargo.lock b/Cargo.lock index b491c2e6..c6f3c440 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,6 +202,7 @@ dependencies = [ "bdk_kyoto", "bdk_redb", "bdk_wallet", + "bip329", "clap", "clap_complete", "cli-table", @@ -356,6 +357,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "bip329" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689260fce0f6e366877a624be4c87518e80f99951c421ae80c3be6523a51bc9d" +dependencies = [ + "bitcoin", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "bip39" version = "2.2.2" diff --git a/Cargo.toml b/Cargo.toml index 7a1a31b4..c65511b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/src/commands.rs b/src/commands.rs index 44b13f27..ed54a930 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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}; @@ -461,6 +461,22 @@ pub enum OfflineWalletSubCommand { #[arg(env = "BASE64_PSBT", required = true)] psbt: Vec, }, + + /// 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
, + /// 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, + /// 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, + }, } /// Wallet subcommands that needs a blockchain backend. diff --git a/src/handlers.rs b/src/handlers.rs index a98b172d..d1478178 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -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"; @@ -103,6 +137,7 @@ pub fn handle_offline_wallet_subcommand( wallet_opts: &WalletOpts, cli_opts: &CliOpts, offline_subcommand: OfflineWalletSubCommand, + labels: &mut bip329::Labels, ) -> Result { match offline_subcommand { NewAddress => { @@ -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::>(); if cli_opts.pretty { @@ -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 @@ -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 @@ -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 => { @@ -233,6 +323,7 @@ pub fn handle_offline_wallet_subcommand( .collect::>(); let mut rows: Vec> = 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), @@ -240,6 +331,7 @@ pub fn handle_offline_wallet_subcommand( 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 @@ -251,6 +343,7 @@ 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()))?; @@ -258,14 +351,16 @@ pub fn handle_offline_wallet_subcommand( } 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(); @@ -1272,6 +1367,18 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { 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 = { @@ -1302,6 +1409,7 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { &wallet_opts, &cli_opts, offline_subcommand.clone(), + &mut labels, )?; wallet.persist(&mut persister)?; result @@ -1314,8 +1422,13 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { &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 { @@ -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 { diff --git a/src/utils.rs b/src/utils.rs index 76e56a0f..589deb93 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -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}; @@ -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::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 { Ok(OutPoint::from_str(s)?)