Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- Add `Labels::try_from_str_with_metadata()` for preserving output `spendable` field metadata

## [0.4.0] - 2025-03-17

### Breaking Changes
Expand Down
145 changes: 143 additions & 2 deletions src/label.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::{
error::{ExportError, ParseError},
Label, LabelRef, Labels, TransactionRecord,
AddressRecord, ExtendedPublicKeyRecord, InputRecord, Label, LabelRef, Labels, OutputRecord,
OutputSpendableField, ParsedLabels, PublicKeyRecord, SpendableFieldValue, TransactionRecord,
};
use std::{
collections::HashMap,
Expand All @@ -16,7 +17,10 @@ impl Labels {
Self(labels)
}

/// Create a new Labels struct from a string.
/// Create labels from JSONL when normalized BIP329 records are enough
///
/// Use this for normal imports where an omitted output `spendable` field can
/// be treated the same as `spendable: true`
pub fn try_from_str(labels: &str) -> Result<Self, ParseError> {
let labels = labels
.trim()
Expand All @@ -27,6 +31,30 @@ impl Labels {
Ok(Self(labels))
}

/// Create labels while preserving output `spendable` field metadata
///
/// Use this when callers need to distinguish omitted `spendable` fields from
/// explicitly provided booleans or string booleans
pub fn try_from_str_with_metadata(labels: &str) -> Result<ParsedLabels, ParseError> {
let mut output_spendable = Vec::new();
let mut parsed_labels = Vec::new();

for line in labels.trim().lines() {
let line: ParsedLabelLine = serde_json::from_str(line)?;
let (label, spendable) = line.into_label_and_spendable();
parsed_labels.push(label);

if let Some(spendable) = spendable {
output_spendable.push(spendable);
}
}

Ok(ParsedLabels {
labels: Self(parsed_labels),
output_spendable,
})
}

/// Create a new Labels struct from a file.
pub fn try_from_file(path: impl AsRef<Path>) -> Result<Self, ParseError> {
let file = File::open(path.as_ref())?;
Expand Down Expand Up @@ -119,6 +147,62 @@ impl Labels {
}
}

#[derive(serde::Deserialize)]
#[serde(tag = "type")]
enum ParsedLabelLine {
#[serde(rename = "tx")]
Transaction(TransactionRecord),
#[serde(rename = "addr")]
Address(AddressRecord),
#[serde(rename = "pubkey")]
PublicKey(PublicKeyRecord),
#[serde(rename = "input")]
Input(InputRecord),
#[serde(rename = "output")]
Output(ParsedOutputRecord),
#[serde(rename = "xpub")]
ExtendedPublicKey(ExtendedPublicKeyRecord),
}

impl ParsedLabelLine {
fn into_label_and_spendable(self) -> (Label, Option<OutputSpendableField>) {
match self {
Self::Transaction(record) => (Label::Transaction(record), None),
Self::Address(record) => (Label::Address(record), None),
Self::PublicKey(record) => (Label::PublicKey(record), None),
Self::Input(record) => (Label::Input(record), None),
Self::Output(record) => record.into_label_and_spendable(),
Self::ExtendedPublicKey(record) => (Label::ExtendedPublicKey(record), None),
}
}
}

#[derive(serde::Deserialize)]
struct ParsedOutputRecord {
#[serde(rename = "ref")]
ref_: bitcoin::OutPoint,
label: Option<String>,
#[serde(default)]
spendable: SpendableFieldValue,
}

impl ParsedOutputRecord {
fn into_label_and_spendable(self) -> (Label, Option<OutputSpendableField>) {
let spendable = self.spendable.explicit_value().unwrap_or(true);
let label = Label::Output(OutputRecord {
ref_: self.ref_,
label: self.label,
spendable,
});
let output_spendable = OutputSpendableField {
ref_: self.ref_,
value: self.spendable,
};

(label, Some(output_spendable))
}
}

impl Label {
/// Create a new Label struct from a string.
pub fn try_from_str(label: &str) -> Result<Self, ParseError> {
Expand Down Expand Up @@ -337,6 +421,63 @@ mod tests {
};
}

#[test]
fn test_output_spendable_metadata_omitted() {
let jsonl = r#"{"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output" }"#;

let labels = Labels::try_from_str_with_metadata(jsonl).unwrap();

assert_eq!(
labels.output_spendable[0].value,
SpendableFieldValue::Omitted
);
}

#[test]
fn test_output_spendable_metadata_boolean() {
let jsonl = r#"{"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output", "spendable": false}"#;

let labels = Labels::try_from_str_with_metadata(jsonl).unwrap();

assert_eq!(
labels.output_spendable[0].value,
SpendableFieldValue::Boolean(false)
);
}

#[test]
fn test_output_spendable_metadata_string() {
let jsonl = r#"{"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output", "spendable": "true"}"#;

let labels = Labels::try_from_str_with_metadata(jsonl).unwrap();

assert_eq!(
labels.output_spendable[0].value,
SpendableFieldValue::String(true)
);
}

#[test]
fn test_output_spendable_metadata_mixed_labels() {
let jsonl = r#"{"type": "tx", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd", "label": "Transaction"}
{"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output", "spendable": "false"}
{"type": "addr", "ref": "bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c", "label": "Address"}"#;

let labels = Labels::try_from_str_with_metadata(jsonl).unwrap();

assert_eq!(labels.labels.len(), 3);
assert_eq!(labels.output_spendable.len(), 1);
assert_eq!(
labels.output_spendable[0].value,
SpendableFieldValue::String(false)
);

let Label::Output(record) = &labels.labels[1] else {
panic!("Expected Output");
};
assert!(!record.spendable());
}

#[test]
fn test_export_to_writer() {
let mut buffer = Vec::new();
Expand Down
69 changes: 69 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,75 @@ use std::fmt::Display;
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct Labels(Vec<Label>);

/// A parsed BIP329 label set with metadata that is lost by [`Labels`]
///
/// Returned by [`Labels::try_from_str_with_metadata`] for imports that need
/// access to both normalized labels and output-specific JSON field metadata
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct ParsedLabels {
/// The normalized BIP329 labels
pub labels: Labels,
/// The original `spendable` field state for each output label
pub output_spendable: Vec<OutputSpendableField>,
}

/// Presence and JSON representation of an output `spendable` field
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct OutputSpendableField {
/// The output reference whose `spendable` metadata was captured
pub ref_: bitcoin::OutPoint,
/// Whether `spendable` was omitted, a boolean, or a string boolean
pub value: SpendableFieldValue,
}

/// The parsed JSON representation of an output `spendable` field
#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
pub enum SpendableFieldValue {
/// The field was not present in the original JSON object
#[default]
Omitted,
/// The field was present as a JSON boolean
Boolean(bool),
/// The field was present as a JSON string containing `true` or `false`
String(bool),
}

impl SpendableFieldValue {
/// Return the explicit boolean value, or `None` when the field was omitted
pub fn explicit_value(&self) -> Option<bool> {
match self {
Self::Omitted => None,
Self::Boolean(value) | Self::String(value) => Some(*value),
}
}
}

impl<'de> Deserialize<'de> for SpendableFieldValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum SpendableField {
Bool(bool),
String(String),
}

match SpendableField::deserialize(deserializer)? {
SpendableField::Bool(value) => Ok(Self::Boolean(value)),
SpendableField::String(value) => match value.to_ascii_lowercase().as_str() {
"true" => Ok(Self::String(true)),
"false" => Ok(Self::String(false)),
string => {
let message = format!("Invalid boolean string: {string}");
Err(serde::de::Error::custom(message))
}
},
}
}
}

/// The main data structure for BIP329 labels.
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
#[serde(tag = "type")]
Expand Down
27 changes: 10 additions & 17 deletions src/serde_util.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
use serde::{Deserialize as _, Deserializer};
use crate::SpendableFieldValue;
use serde::Deserializer;

pub(crate) fn deserialize_string_or_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum StringOrBool {
String(String),
Bool(bool),
}
// keep normal output parsing aligned with metadata-aware parsing
let value = <SpendableFieldValue as serde::Deserialize>::deserialize(deserializer)?;

match StringOrBool::deserialize(deserializer)? {
StringOrBool::Bool(b) => Ok(b),
StringOrBool::String(s) => match s.to_ascii_lowercase().as_str() {
"true" => Ok(true),
"false" => Ok(false),
string => {
let msg = format!("Invalid boolean string: {string}");
Err(serde::de::Error::custom(msg))
}
},
match value {
SpendableFieldValue::Boolean(value) | SpendableFieldValue::String(value) => Ok(value),
SpendableFieldValue::Omitted => {
unreachable!("serde only calls this deserializer for present spendable fields")
}
}
}
Loading