From 007abc08c2e86e4db6b64168572441b3fdc5913b Mon Sep 17 00:00:00 2001 From: musab1258 Date: Mon, 30 Mar 2026 14:25:13 +0100 Subject: [PATCH 1/4] feat: initialize and persist BIP-329 wallet labels - Loads the \'Labels\' state from \'.labels.jsonl\' after \'load_wallet_config\'. - Updates \'handle_offline_wallet_subcommand\' to accept mutable label state. - Exports and saves the in-memory labels to disk before the command return. --- src/handlers.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/handlers.rs b/src/handlers.rs index a98b172d..1754ec0d 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -103,6 +103,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 => { @@ -1273,6 +1274,15 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { 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 = { let mut persister: Persister = match &wallet_opts.database_type { @@ -1302,6 +1312,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 +1325,11 @@ 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 { From 3807cb3e70bd249898364f5ad5bcf75e122afe8c Mon Sep 17 00:00:00 2001 From: musab1258 Date: Mon, 30 Mar 2026 17:35:26 +0100 Subject: [PATCH 2/4] feat: display BIP-329 labels in unspent and transactions outputs - Injects label lookups into the `unspent` and `transactions` match arms. - Updates the `--pretty` table formatting to include a new 'Label' column (using an em dash for missing labels). - Appends the label string to the standard JSON output --- src/handlers.rs | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/handlers.rs b/src/handlers.rs index 1754ec0d..782d9ab1 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -91,6 +91,27 @@ use { bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint}, }; +use bip329::LabelRef; + +/// 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), + 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()) +} + #[cfg(feature = "compiler")] const NUMS_UNSPENDABLE_KEY_HEX: &str = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; @@ -173,6 +194,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 @@ -189,6 +212,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 @@ -202,12 +226,18 @@ 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 => { @@ -234,6 +264,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), @@ -241,6 +272,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 @@ -252,6 +284,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()))?; @@ -259,14 +292,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(); @@ -1329,7 +1364,7 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { )? }; labels.export_to_file(&label_file_path).map_err(|e| Error::Generic(format!("Failed to save labels: {}", e)))?; - + Ok(result) } CliSubCommand::Wallet { From 6d7ba35c50c9805c1378b924c15ee7c1f88b414e Mon Sep 17 00:00:00 2001 From: musab1258 Date: Mon, 30 Mar 2026 18:28:02 +0100 Subject: [PATCH 3/4] feat: add label command to tag addresses, txids and utxos - Introduces the `Label` variant to `OfflineWalletSubCommand` with strict `clap` conflict rules. - Adds a `parse_txid` helper to utils.rs. - Implements an `add_or_add_update_label` helper in `handlers.rs` to mutate the in-memory label state in place. --- Cargo.lock | 13 +++++++ Cargo.toml | 1 + src/commands.rs | 18 ++++++++- src/handlers.rs | 98 ++++++++++++++++++++++++++++++++++++++++++------- src/utils.rs | 7 +++- 5 files changed, 121 insertions(+), 16 deletions(-) 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 782d9ab1..88b57fa3 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -92,12 +92,14 @@ use { }; 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), + 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 @@ -106,12 +108,23 @@ fn format_ref_str(item_ref: &LabelRef) -> String { /// 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() + 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"; @@ -178,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 { @@ -226,17 +279,23 @@ pub fn handle_offline_wallet_subcommand( "Index".cell().bold(true), "Block Height".cell().bold(true), "Block Hash".cell().bold(true), - "Label".cell().bold(true) + "Label".cell().bold(true), ]) .display() .map_err(|e| Error::Generic(e.to_string()))?; Ok(format!("{table}")) } else { - 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(); + 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)?) } } @@ -1308,13 +1367,16 @@ 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(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))), }; @@ -1363,7 +1425,9 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { &mut labels, )? }; - labels.export_to_file(&label_file_path).map_err(|e| Error::Generic(format!("Failed to save labels: {}", e)))?; + labels + .export_to_file(&label_file_path) + .map_err(|e| Error::Generic(format!("Failed to save labels: {}", e)))?; Ok(result) } @@ -1521,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..aca51fe4 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 { + Ok(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)?) From 4dd8d9fbbc98d5d5d99e247db5c3ca8260431044 Mon Sep 17 00:00:00 2001 From: musab1258 Date: Tue, 31 Mar 2026 05:22:05 +0100 Subject: [PATCH 4/4] fix: resolve clippy warnings introduced by BIP-329 label feature --- src/handlers.rs | 16 ++++++++-------- src/utils.rs | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/handlers.rs b/src/handlers.rs index 88b57fa3..d1478178 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -98,10 +98,10 @@ 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::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), + LabelRef::Output(op) => format!("output:{op}"), + LabelRef::Input(op) => format!("input:{op}"), _ => item_ref.to_string(), // Fallback for pubkey/xpub } } @@ -223,7 +223,7 @@ pub fn handle_offline_wallet_subcommand( add_or_update_label(labels, new_label); if cli_opts.pretty { - Ok(format!("Successfully applied label '{}'", label_str)) + Ok(format!("Successfully applied label '{label_str}'")) } else { Ok(serde_json::to_string_pretty(&json!({ "message": "Label successfully applied", @@ -323,7 +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)); + let label_str = get_label_string(labels, &format!("txid:{txid}")); rows.push(vec![ txid.cell(), version.to_string().cell().justify(Justify::Right), @@ -360,7 +360,7 @@ pub fn handle_offline_wallet_subcommand( "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)), + "label": get_label_string(labels, &format!("txid:{txid}")), }) }) .collect(); @@ -1377,7 +1377,7 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { { bip329::Labels::default() } - Err(e) => return Err(Error::Generic(format!("Failed to load labels: {}", e))), + Err(e) => return Err(Error::Generic(format!("Failed to load labels: {e}"))), }; #[cfg(any(feature = "sqlite", feature = "redb"))] @@ -1427,7 +1427,7 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { }; labels .export_to_file(&label_file_path) - .map_err(|e| Error::Generic(format!("Failed to save labels: {}", e)))?; + .map_err(|e| Error::Generic(format!("Failed to save labels: {e}")))?; Ok(result) } diff --git a/src/utils.rs b/src/utils.rs index aca51fe4..589deb93 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -86,7 +86,7 @@ pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> { /// Parse a txid argument from cli input. pub(crate) fn parse_txid(s: &str) -> Result { - Ok(Txid::from_str(s).map_err(|e| Error::Generic(e.to_string()))?) + Txid::from_str(s).map_err(|e| Error::Generic(e.to_string())) } /// Parse a outpoint (Txid:Vout) argument from cli input.