From bb3aca3be2b6599ba0adac14054c1841b91706e2 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 2 Jul 2026 15:56:15 -0500 Subject: [PATCH] Preserve optional output spendability --- CHANGELOG.md | 5 +++++ src/label.rs | 35 +++++++++++++++++++++++++++++------ src/lib.rs | 13 +++++-------- src/serde_util.rs | 11 ++++------- 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b056c..8e9c631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Breaking Changes + +- Change `OutputRecord.spendable` from `bool` to `Option` so omitted + `spendable` fields round-trip as absent and explicit booleans remain explicit + ## [0.5.0] - 2026-06-18 ### Added diff --git a/src/label.rs b/src/label.rs index 04ae080..1e88ca1 100644 --- a/src/label.rs +++ b/src/label.rs @@ -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 { let labels = labels .trim() @@ -188,11 +188,10 @@ struct ParsedOutputRecord { impl ParsedOutputRecord { fn into_label_and_spendable(self) -> (Label, Option) { - 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_, @@ -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"); } @@ -416,7 +415,7 @@ mod tests { .unwrap() ); assert_eq!(*label, Some("Output".to_string())); - assert!(*spendable); + assert_eq!(*spendable, None); assert!(record.spendable()); }; } @@ -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 @@ -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) @@ -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"} @@ -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()); } diff --git a/src/lib.rs b/src/lib.rs index 5dbe4dc..9405c5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -217,10 +217,11 @@ pub struct OutputRecord { pub label: Option, #[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, } /// An extended public key label. @@ -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 -} diff --git a/src/serde_util.rs b/src/serde_util.rs index b1b6b5d..0496c41 100644 --- a/src/serde_util.rs +++ b/src/serde_util.rs @@ -1,17 +1,14 @@ use crate::SpendableFieldValue; use serde::Deserializer; -pub(crate) fn deserialize_string_or_bool<'de, D>(deserializer: D) -> Result +pub(crate) fn deserialize_optional_string_or_bool<'de, D>( + deserializer: D, +) -> Result, D::Error> where D: Deserializer<'de>, { // keep normal output parsing aligned with metadata-aware parsing let value = ::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()) }