-
Notifications
You must be signed in to change notification settings - Fork 93
Feat: Add bip329 wallet labels #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
007abc0
3807cb3
6d7ba35
4dd8d9f
8422d82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<String, Error> { | ||
| match offline_subcommand { | ||
| NewAddress => { | ||
|
|
@@ -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, | ||
| }) | ||
| } 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To avoid incidental data loss, I would suggest adding an |
||
| } | ||
|
|
||
| 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 { | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 => { | ||
|
|
@@ -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 | ||
|
|
@@ -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(); | ||
|
|
@@ -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}"))), | ||
| }; | ||
|
|
||
| #[cfg(any(feature = "sqlite", feature = "redb"))] | ||
| let result = { | ||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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(); | ||
| 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 { | ||
|
|
||
There was a problem hiding this comment.
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?