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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Breaking Changes

- Change `OutputRecord.spendable` from `bool` to `Option<bool>` so omitted
`spendable` fields round-trip as absent and explicit booleans remain explicit

## [0.5.0] - 2026-06-18

### Added
Expand Down
35 changes: 29 additions & 6 deletions src/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ impl Labels {

/// 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`
/// Use this for normal imports where only the explicit `spendable` value is
/// needed and the original JSON representation can be normalized
pub fn try_from_str(labels: &str) -> Result<Self, ParseError> {
let labels = labels
.trim()
Expand Down Expand Up @@ -188,11 +188,10 @@ struct ParsedOutputRecord {

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,
spendable: self.spendable.explicit_value(),
});
let output_spendable = OutputSpendableField {
ref_: self.ref_,
Expand Down Expand Up @@ -362,7 +361,7 @@ mod tests {
.unwrap()
);
assert_eq!(label, &Some("Output".to_string()));
assert!(!*spendable);
assert_eq!(*spendable, Some(false));
} else {
panic!("Expected Output");
}
Expand Down Expand Up @@ -416,7 +415,7 @@ mod tests {
.unwrap()
);
assert_eq!(*label, Some("Output".to_string()));
assert!(*spendable);
assert_eq!(*spendable, None);
assert!(record.spendable());
};
}
Expand All @@ -426,7 +425,11 @@ mod tests {
let jsonl = r#"{"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output" }"#;

let labels = Labels::try_from_str_with_metadata(jsonl).unwrap();
let Label::Output(record) = &labels.labels[0] else {
panic!("Expected Output");
};

assert_eq!(record.spendable, None);
assert_eq!(
labels.output_spendable[0].value,
SpendableFieldValue::Omitted
Expand All @@ -438,7 +441,11 @@ mod tests {
let jsonl = r#"{"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output", "spendable": false}"#;

let labels = Labels::try_from_str_with_metadata(jsonl).unwrap();
let Label::Output(record) = &labels.labels[0] else {
panic!("Expected Output");
};

assert_eq!(record.spendable, Some(false));
assert_eq!(
labels.output_spendable[0].value,
SpendableFieldValue::Boolean(false)
Expand All @@ -450,13 +457,28 @@ mod tests {
let jsonl = r#"{"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output", "spendable": "true"}"#;

let labels = Labels::try_from_str_with_metadata(jsonl).unwrap();
let Label::Output(record) = &labels.labels[0] else {
panic!("Expected Output");
};

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

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

let labels = Labels::try_from_str(jsonl).unwrap();
let exported = labels.export().unwrap();

assert!(exported.contains(r#""label":"Output""#));
assert!(!exported.contains("spendable"));
}

#[test]
fn test_output_spendable_metadata_mixed_labels() {
let jsonl = r#"{"type": "tx", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd", "label": "Transaction"}
Expand All @@ -475,6 +497,7 @@ mod tests {
let Label::Output(record) = &labels.labels[1] else {
panic!("Expected Output");
};
assert_eq!(record.spendable, Some(false));
assert!(!record.spendable());
}

Expand Down
13 changes: 5 additions & 8 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,11 @@ pub struct OutputRecord {
pub label: Option<String>,

#[serde(
default = "default_true",
deserialize_with = "serde_util::deserialize_string_or_bool"
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "serde_util::deserialize_optional_string_or_bool"
)]
pub spendable: bool,
pub spendable: Option<bool>,
}

/// An extended public key label.
Expand All @@ -234,10 +235,6 @@ pub struct ExtendedPublicKeyRecord {
impl OutputRecord {
/// Defaults to being spendable if no spendable field is present
pub fn spendable(&self) -> bool {
self.spendable
self.spendable.unwrap_or(true)
}
}

fn default_true() -> bool {
true
}
11 changes: 4 additions & 7 deletions src/serde_util.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
use crate::SpendableFieldValue;
use serde::Deserializer;

pub(crate) fn deserialize_string_or_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
pub(crate) fn deserialize_optional_string_or_bool<'de, D>(
deserializer: D,
) -> Result<Option<bool>, D::Error>
where
D: Deserializer<'de>,
{
// keep normal output parsing aligned with metadata-aware parsing
let value = <SpendableFieldValue as serde::Deserialize>::deserialize(deserializer)?;

match value {
SpendableFieldValue::Boolean(value) | SpendableFieldValue::String(value) => Ok(value),
SpendableFieldValue::Omitted => {
unreachable!("serde only calls this deserializer for present spendable fields")
}
}
Ok(value.explicit_value())
}
Loading