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
31 changes: 30 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,35 @@ 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>,
},
/// Import from an existing BIP-329 JSONL label file, merging with current labels.
ImportLabels {
/// The jsonl label file path
#[arg(required = true)]
file: std::path::PathBuf,
},

/// Export current labels to a BIP-329 JSONL file.
ExportLabels {
/// The jsonl label file path
#[arg(required = true)]
file: std::path::PathBuf,
},
}

/// Wallet subcommands that needs a blockchain backend.
Expand Down
173 changes: 168 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why fallback, rather than handle other types?

}
}

/// 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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlabeled items render as the literal string . User can set a real label of and JSON then can't tell "no label" from a label.

Reproduction (regtest, two received txs, label set on the first only):

# 1. both txs render "—", though no labels set yet
$ bdk wallet --wallet label_demo transactions | jq '.[] | {txid, label}'
{ "txid": "5fe2524f0c46bb12767d1865114031cd631578ac05422a767c0edf23f5c0aeaa", "label": "—" }
{ "txid": "55d2b85dbf49d78c7070d83f20443e694d90a65498d44bbe84f3c7aa0b55a465", "label": "—" }

# 2. labels file is empty so the "—" above is the default, not a stored label
$ cat labels.jsonl

# 3. set a real "—" label on the first tx only
$ bdk wallet --wallet label_demo label "—" --txid 5fe2524f0c46bb12767d1865114031cd631578ac05422a767c0edf23f5c0aeaa

# 4. file now has exactly one entry, for the first tx
$ cat labels.jsonl
{"type":"tx","ref":"5fe2524f0c46bb12767d1865114031cd631578ac05422a767c0edf23f5c0aeaa","label":"—"}

# 5. output is unchanged - both still "—", labeled and unlabeled indistinguishable
$ bdk wallet --wallet label_demo transactions | jq '.[] | {txid, label}'
{ "txid": "5fe2524f0c46bb12767d1865114031cd631578ac05422a767c0edf23f5c0aeaa", "label": "—" }
{ "txid": "55d2b85dbf49d78c7070d83f20443e694d90a65498d44bbe84f3c7aa0b55a465", "label": "—" }

Fix: return Option<String> (None when absent) so JSON serializes null for unlabeled and "—" only for a real label.

P.S. bdk is my custom zsh fn for bdk-cli with additional cli-args.

}

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,90 @@ 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

label --utxo hardcodes spendable: false, which in BIP-329 marks the UTXO as frozen. Naming a coin shouldn't freeze it. And it can't just be flipped to a hardcoded true either - any fixed value clobbers the existing spendable - a true would silently unfreeze a UTXO the user had frozen, on a mere rename. A naming command must not touch spendable at all.

bdk-cli ignores the field locally, but BIP-329 is for interchange - importing into Sparrow/Nunchuk/etc. shows the UTXO as frozen.

Reproduced (regtest) - just naming a coin writes spendable:false:

$ bdk wallet --wallet label_demo label "my coin" --utxo 55d2b85dbf49d78c7070d83f20443e694d90a65498d44bbe84f3c7aa0b55a465:0
$ cat labels.jsonl | jq
{ "type": "output", "ref": "55d2b85dbf49d78c7070d83f20443e694d90a65498d44bbe84f3c7aa0b55a465:0", "label": "my coin", "spendable": false }

Fix: preserve the existing record's spendable (read-modify), default to true only for a new record - never hardcode it.

})
} 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
}))?)
}
}
ImportLabels { file } => {
let imported_labels = bip329::Labels::try_from_file(&file).map_err(|e| {
Error::Generic(format!(
"Failed to import labels from {}: {e}",
file.display()
))
})?;

for label in imported_labels.into_iter() {
add_or_update_label(labels, label);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid incidental data loss, I would suggest adding an --override flag on import. If the flag is not provided, either the command fails, or only new labels are added

}

if cli_opts.pretty {
Ok(format!(
"Successfully imported labels from '{}'",
file.display()
))
} else {
Ok(serde_json::to_string_pretty(&json!({
"message": "Labels successfully imported",
"file": file.display().to_string()
}))?)
}
}
ExportLabels { file } => {
labels.export_to_file(&file).map_err(|e| {
Error::Generic(format!(
"Failed to export labels to {}: {e}",
file.display()
))
})?;

if cli_opts.pretty {
Ok(format!(
"Labels successfully exported to '{}'",
file.display()
))
} else {
Ok(serde_json::to_string_pretty(&json!({
"message": "Labels successfully exported",
"file": file.display().to_string()
}))?)
}
}
Unspent => {
let utxos = wallet.list_unspent().collect::<Vec<_>>();
if cli_opts.pretty {
Expand All @@ -172,6 +291,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 +309,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 +323,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 +367,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 +387,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 +1411,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}"))),
};
Comment on lines +1417 to +1425

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed that every wallet command loads (and rewrites) labels.jsonl, and the load is a hard error. Combined with export_to_file writing the file without a trailing newline, appending a record - the usual way to add a line to JSONL - glues it onto the last line and makes the file invalid. After that any command fails, even ones unrelated to labels like balance:

$ echo '{"type":"output","ref":"5fe2524f0c46bb12767d1865114031cd631578ac05422a767c0edf23f5c0aeaa:0","label":"x"}' >> labels.jsonl

$ bdk wallet --wallet label_demo balance
[ERROR bdk_cli] Generic error: Failed to load labels: Unable to parse file: trailing characters at line 1 column 129

So a labels file touched by any other tool (or a partial write) can block the whole wallet CLI, including commands that have nothing to do with labels. Might be worth a trailing newline on export and/or skipping a bad labels file instead of failing the command.


#[cfg(any(feature = "sqlite", feature = "redb"))]
let result = {
Expand Down Expand Up @@ -1302,6 +1453,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 +1466,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 +1629,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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In REPL mode label reading doesn't seem to work - the "my coin" label shows up outside REPL but is empty inside.

$ bdk wallet --wallet label_demo unspent | jq '.[] | {outpoint, label}'
{ "outpoint": "55d2b85dbf49d78c7070d83f20443e694d90a65498d44bbe84f3c7aa0b55a465:0", "label": "my coin" }

$ bdk repl --wallet label_demo
> wallet unspent
[ { ... "outpoint": "55d2b85dbf49d78c7070d83f20443e694d90a65498d44bbe84f3c7aa0b55a465:0", "label": "—" ... } ]

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
Loading