Skip to content

Feat: Add bip329 wallet labels#266

Open
Musab1258 wants to merge 5 commits into
bitcoindevkit:masterfrom
Musab1258:feat/add-bip329-wallet-labels
Open

Feat: Add bip329 wallet labels#266
Musab1258 wants to merge 5 commits into
bitcoindevkit:masterfrom
Musab1258:feat/add-bip329-wallet-labels

Conversation

@Musab1258

@Musab1258 Musab1258 commented Mar 31, 2026

Copy link
Copy Markdown

Description

This PR adds BIP-329 wallet label support to bdk-cli, to fix #184. It uses the [bip329] crate to manage label data, persists it in a separate labels.jsonl file within the wallet's data directory (e.g. ~/.bdk-bitcoin/<wallet_name>/labels.jsonl), and keeps it decoupled from the wallet's SQLite/redb database. It also exports and imports label files.

This PR includes the following functionality:

  • Set labels: It uses a new label offline subcommand to attach a human-readable label to either a transaction ID, address, or UTXO (outpoint).
  • Persist labels: It ensures that Labels are loaded from disk at the start of every offline wallet command and saved back after the command completes, so they survive across sessions.
  • Display labels: The unspent and transactions commands now include a Label column in both --pretty table output and standard JSON output, showing the stored label or an em dash if none exists.
  • Import labels: The import_labels subcommand merges labels from an external BIP-329 JSONL file into the wallet's current label state. If a label for the same item already exists, it is overwritten by the imported one.
  • Export labels: The export_labels subcommand writes the wallet's current label state to a user-specified BIP-329 JSONL file, suitable for backup or use in other BIP-329 compliant wallets.

Notes to the reviewers

  • Labels are stored in a separate labels.jsonl file rather than inside the wallet database. This keeps the label format portable and interoperable with other BIP-329 compliant wallets.
  • The Label import from bip329 is aliased as BipLabel in handlers.rs to avoid a name clash with the OfflineWalletSubCommand::Label variant brought into scope via use crate::commands::OfflineWalletSubCommand::*.
  • The import_labels command uses the existing add_or_update_label helper to merge incoming labels one at a time, new label references are inserted, and existing label references are overwritten by the imported value.
  • The export_labels command writes to a user-specified path, which is independent of the wallet's internal labels.jsonl file. The internal file is always managed automatically by the save-on-exit mechanism, so the two files remain separate.

Changelog notice

Added

  • New label subcommand to OfflineWalletSubCommand for tagging transactions, addresses, and UTXOs with human-readable labels.
  • New import_labels subcommand to merge labels from an external BIP-329 JSONL file into the wallet's current label state.
  • New export_labels subcommand to back up the wallet's current label state to a user-specified BIP-329 JSONL file.
  • BIP-329 compliant label persistence via a labels.jsonl file in the wallet data directory.
  • Label column in unspent and transactions output (both --pretty and JSON).
  • parse_txid helper in utils.rs.
  • bip329 = "0.4.0" dependency in Cargo.toml.

Checklists

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature
  • I've updated CHANGELOG.md

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

- Loads the \'Labels\' state from \'<wallet>.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.
- 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
- 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.
@Musab1258

Copy link
Copy Markdown
Author

Hi @tvpeter, I am done with this. You can check it out when you are free.

I will start working on the follow-up PR to add import and export of label files.

- Introduces `ImportLabels` and `ExportLabels` variants to `OfflineWalletSubCommand` for CLI parsing.

- Implements label merging from external JSONL files using `try_from_file` and `add_or_update_label`.

- Enables backing up the current label state to a user-specified path using `export_to_file`.
@Musab1258

Copy link
Copy Markdown
Author

Hi @tvpeter, I eventually decided to add the import and export of BIP-329 JSONL files' implementation here when, after I was done with the implementation, I saw that it was something that could fit into this PR without bloating it.

So, instead of creating a second PR and having reviewers go through the stress of reviewing two PRs that can probably fit into one, I decided to make it one PR.

@codecov

codecov Bot commented Apr 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 132 lines in your changes missing coverage. Please review.
✅ Project coverage is 10.59%. Comparing base (07fd32f) to head (8422d82).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/handlers.rs 0.00% 129 Missing ⚠️
src/utils.rs 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #266      +/-   ##
==========================================
- Coverage   11.13%   10.59%   -0.55%     
==========================================
  Files           8        8              
  Lines        2488     2615     +127     
==========================================
  Hits          277      277              
- Misses       2211     2338     +127     
Flag Coverage Δ
rust 10.59% <0.00%> (-0.55%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@notmandatory

Copy link
Copy Markdown
Member

This looks like a very useful feature and one that I'd love to see in the bdk-cli project. But instead of coding it directly into bdk-cli how would you feel about creating a new bdk_bip329 (or bdk-bip329) crate similar to bdk-bip322?

It will be a little more complicated than bdk-bip322 since you also need some sort of persistence trait to tie into the user's choice of database. But it would be nice to have something easy to integrate with any bdk_wallet based project.

@Musab1258

Musab1258 commented Apr 23, 2026

Copy link
Copy Markdown
Author

Thanks, @notmandatory, for the review and the suggestion! I really like the idea of creating a new bdk_bip329 crate similar to bdk-bip322 so other bdk_wallet projects can easily integrate it, and I would love to work on it.

Regarding the persistence trait to tie into the user's choice of database, I was thinking we could build an interface that lets users store labels internally in their database of choice (like SQLite or Redb). To keep it efficient, we could mirror bdk_wallet's approach by abstracting the backend and using a changeset pattern to persist only the diffs.

To maintain strict BIP-329 compliance, the crate would then provide dedicated import and export methods to convert the internal database state to and from the standard JSONL format.

Does this approach align with what you have in mind?

@notmandatory

Copy link
Copy Markdown
Member

Yes this sounds like a good approach.

@brh28

brh28 commented Apr 26, 2026

Copy link
Copy Markdown

Hey @Musab1258, glad to see you're working on this. I've been playing around with bdk-cli and had some thoughts around this

My thinking is to create a bdk_labels tool. The motivation is similar to the idea of a bdk_bip329 crate, but with some expanded functionality. Some ideas:

  1. Import bip329 jsonl (e.g cat mywallet.jsonl | bdk_labels import --bip329)
  2. Import existing bdk wallets (bdk_labels import --config # reads $DATADIR, else ~/.bdk-bitcoin)
  3. Add a label to existing wallet using bip329 keys as arguments (e.g bdk-labels add --type tx --ref <txid> --label "groceries")
  4. Add a label to types not-associated with a wallet. (e.g bdk-labels add --type addr --ref <addr> --label "Satoshi's Coins")
  5. Export bip329 with filter arguments (bdk_labels export --origin multisigwallet)
  6. Support a list of labels (or "tags" to distinguish nomenclature) (e.g bdk-labels tag --type output --ref <txid:3> "cost_basis_lot:20260426" "project_income" "kyc"). These tags can be serialized into the bip329 label
  7. Query labels/tags (e.g bdk_labels list --type tx --tag project_income)

Just brainstorming somethings that I think would be useful. Would love to hear know your thoughts

@Musab1258

Copy link
Copy Markdown
Author

@notmandatory, since I don't have permissions to create a repository in the bitcoindevkit organization, what should the next steps be? Would you set up an empty repository within the organization (similar to how bdk-bip322 is set up) for me to work from?

Yes this sounds like a good approach.

@Musab1258

Musab1258 commented Apr 29, 2026

Copy link
Copy Markdown
Author

@brh28, these are really nice ideas for expanding the labeling functionality.

Actually, some of the features you mentioned (like importing a BIP-329 JSONL file and adding labels to existing wallet items) are already implemented in my current PR here. Since @notmandatory suggested implementing it in a new bdk_bip329 crate, we can implement the existing add, import, and export functionalities first. Then we can add the multi-tagging and filter logic later.

Also, the examples you made (like cat mywallet.jsonl | bdk_labels import --bip329 and the rest) show that you wanted the tool to be a CLI. But, I think what @notmandatory wants is a crate that can be integrated into other crates and CLI tools

@notmandatory may also have something to say about this.

Hey @Musab1258, glad to see you're working on this. I've been playing around with bdk-cli and had some thoughts around this

My thinking is to create a bdk_labels tool. The motivation is similar to the idea of a bdk_bip329 crate, but with some expanded functionality. Some ideas:

  1. Import bip329 jsonl (e.g cat mywallet.jsonl | bdk_labels import --bip329)
  2. Import existing bdk wallets (bdk_labels import --config # reads $DATADIR, else ~/.bdk-bitcoin)
  3. Add a label to existing wallet using bip329 keys as arguments (e.g bdk-labels add --type tx --ref <txid> --label "groceries")
  4. Add a label to types not-associated with a wallet. (e.g bdk-labels add --type addr --ref <addr> --label "Satoshi's Coins")
  5. Export bip329 with filter arguments (bdk_labels export --origin multisigwallet)
  6. Support a list of labels (or "tags" to distinguish nomenclature) (e.g bdk-labels tag --type output --ref <txid:3> "cost_basis_lot:20260426" "project_income" "kyc"). These tags can be serialized into the bip329 label
  7. Query labels/tags (e.g bdk_labels list --type tx --tag project_income)

Just brainstorming somethings that I think would be useful. Would love to hear know your thoughts

@brh28

brh28 commented Apr 29, 2026

Copy link
Copy Markdown

Thanks @Musab1258

I created my own repo bdk-labels to start implementing some of these features. As you pointed out, many of the features overlap with what you're working on, so it's quite similar to your PR, but perhaps it'll be of interest to you.

The biggest feature difference is tags, which are currently encoded into the label field but would ideally be it's own bip329 field.

Also, my implementation uses an existing bip329 crate if that's something that interests you. you're already using it :)

Comment thread src/handlers.rs
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?

@notmandatory

Copy link
Copy Markdown
Member

@notmandatory, since I don't have permissions to create a repository in the bitcoindevkit organization, what should the next steps be? Would you set up an empty repository within the organization (similar to how bdk-bip322 is set up) for me to work from?

Go ahead and create a new repo in your personal account and we can move it into the bitcoindevkit org when it's ready. I also prefer @brh28 's repo name bdk-labels. And glad to see you two collaborating on this project.

@notmandatory

Copy link
Copy Markdown
Member

@brh28, these are really nice ideas for expanding the labeling functionality.

Actually, some of the features you mentioned (like importing a BIP-329 JSONL file and adding labels to existing wallet items) are already implemented in my current PR here. Since @notmandatory suggested implementing it in a new bdk_bip329 crate, we can implement the existing add, import, and export functionalities first. Then we can add the multi-tagging and filter logic later.

@Musab1258 you're correct that I do have a general purpose library in mind for this feature, not something only for use in bdk-cli. But still good to keep in mind how it will be used in this cli project.

Comment thread src/handlers.rs
})?;

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

@Musab1258

Copy link
Copy Markdown
Author

@notmandatory, I have set up the bdk-labels repository on my personal account. Here it is: bdk-labels.

@brh28, I would love to add you as a collaborator so we can work together on this crate. I have sent you a collaboration invite. You should receive it in the mail associated with your GitHub account.

When will you be free so we can discuss and plan out the building of the crate?

@brh28

brh28 commented May 3, 2026

Copy link
Copy Markdown

Yeah man, I'll mention here that I'm mainly interested in serving that tagging feature at the moment as it helps with a particular problem I'm having, but I really like the idea of a wallet cli tool, so happy to help where I can

@brh28

brh28 commented May 7, 2026

Copy link
Copy Markdown

@notmandatory Is it just the bdk-labels code that you want to exist separately, or are you imagining an entirely different tool? I'm thinking labeling should be part of the bdk-cli wallet. For example, include a label arg on new_address command:

bdk-cli wallet new_address --label "from Alice"

Perhaps even a new addresses command that lists all wallet addresses along with associated labels

@notmandatory

Copy link
Copy Markdown
Member

@notmandatory Is it just the bdk-labels code that you want to exist separately, or are you imagining an entirely different tool? I'm thinking labeling should be part of the bdk-cli wallet. For example, include a label arg on new_address command:

bdk-cli wallet new_address --label "from Alice"

Perhaps even a new addresses command that lists all wallet addresses along with associated labels

Yes I'm thinking the bdk-labels code should be in its own repo. Then we should add commands like you're suggesting to bdk-cli using the bdk-labels as a dependency that adds the needed functions to the bdk_wallet::Wallet.

@Musab1258

Musab1258 commented May 27, 2026

Copy link
Copy Markdown
Author

Hi @notmandatory, I am done with the implementation, testing, and documentation of the bdk-labels. Can you please take a look at it here when you are free?

@Musab1258

Copy link
Copy Markdown
Author

Hi @brh28, I am done with the implementation, testing, and documentation of the bdk-labels. Can you please take a look at it here when you are free?

Yeah man, I'll mention here that I'm mainly interested in serving that tagging feature at the moment as it helps with a particular problem I'm having, but I really like the idea of a wallet cli tool, so happy to help where I can

I think the crate is ready for any feature (tagging, for instance) that we might want to add to it.

Thanks @Musab1258

I created my own repo bdk-labels to start implementing some of these features. As you pointed out, many of the features overlap with what you're working on, so it's quite similar to your PR, but perhaps it'll be of interest to you.

The biggest feature difference is tags, which are currently encoded into the label field but would ideally be it's own bip329 field.

Also, my implementation uses an existing bip329 crate if that's something that interests you. you're already using it :)

But, since tagging is not BIP 329 compliant, the bip329 crate we are using in this crate does not implement it. I feel like if we are to do it and still make our bdk-labels crate BIP329 compliant, We should build an abstraction layer on top of the normal labeled wallet (in this bdk-label crate), which implements the tagging feature.

My fear is that if we add it directly, the labels in bdk-wallet will not be compatible with other wallets that are BIP329 compliant.

@notmandatory

Copy link
Copy Markdown
Member

@thunderbiscuit to keep the discussion in one place I'm commenting on your Musab1258/bdk-labels#1 here.

I like the idea of keeping the labels feature independent of the Wallet. But if we do it this way then we probably don't need a whole bdk-labels crate and only need some examples of how to use the bip329 crate on it's own with a Wallet (what this PR does). The bip329 crate only saves/loads jsonl files, but maybe that's enough?

Sticking with the bdk-labels::LabelledWallet approach give some advantages of being able to access the inner Wallet which is useful for a few things:

  • can validate what you're labeling exists in Wallet
  • fields like "spendable" can be determined based on the Wallet state
  • functions can be added that return items (address, tx, utxos) with any corresponding labels

Or we could go all the way and add labels directly as a new field in Wallet and load/persist them in the Wallet change sets, using bip329 just for import/export.

@Musab1258

Copy link
Copy Markdown
Author

@thunderbiscuit,

I made use of the LabelledWallet adapter because I wanted to add a changeset to the wallet created from bdk_wallet (i.e., a wallet with a label changeset). The pattern allows anyone using the label to add a label(s), validate it against the wallet, and store the result in the changeset.

One of the reasons why the changeset is needed in this crate is that it acts as a diff or a staging area. And if it is not present, the crate would have to overwrite the entire database or rewrite the entire JSONL file every single time a user adds a label.

But if I am to remove the adapter pattern, the crate will not be able to attach its LabelChangeset to the user's wallet. Unless I implement the LabelChangeset logic directly in bdk-wallet. Which kind of reduces the essence of this crate.

But I am willing to move forward with any consensus agreement that is made here.

@va-an va-an left a comment

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.

@Musab1258 thanks for the work on BIP-329 labels! Left a few inline comments, please take a look.

One more thing: the address case of label looks write-only. label --address stores an addr record in labels.jsonl, but nothing reads it back - new_address/unused_address don't show a label, and unspent/transactions only surface utxo and txid labels. So an address label goes into the file and becomes invisible, unlike txid/utxo. Maybe display address labels somewhere, or drop --address until they're readable.

Also, don't forget to rebase on master before further changes!

Comment thread src/handlers.rs
.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.

Comment thread src/handlers.rs
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.

Comment thread src/handlers.rs
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": "—" ... } ]

Comment thread src/handlers.rs
Comment on lines +1417 to +1425
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}"))),
};

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Add BIP-329 wallet label support

4 participants