From 22530f6a2b484d2cb7974250bca5b28b9f26d85d Mon Sep 17 00:00:00 2001 From: "Tobin C. Harding" Date: Thu, 28 Nov 2024 16:39:32 +1100 Subject: [PATCH] Support serde serializing Amount as string Sometimes JSON parsers may munge floats. Instead of using `f64` we can serialize BTC amounts as strings. Includes addition of `alloc` feature gate to `DisplayFullError` to remove lint warnings when building with `--no-default-features`. Close: #894 --- units/Cargo.toml | 2 +- units/src/amount/serde.rs | 115 +++++++++++++++++++++++++++++++++++++- units/src/amount/tests.rs | 68 ++++++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/units/Cargo.toml b/units/Cargo.toml index 61acb6613..682307dde 100644 --- a/units/Cargo.toml +++ b/units/Cargo.toml @@ -15,7 +15,7 @@ exclude = ["tests", "contrib"] [features] default = ["std"] std = ["alloc", "internals/std"] -alloc = ["internals/alloc"] +alloc = ["internals/alloc","serde?/alloc"] [dependencies] internals = { package = "bitcoin-internals", version = "0.4.0" } diff --git a/units/src/amount/serde.rs b/units/src/amount/serde.rs index a485994b7..f5ddc595f 100644 --- a/units/src/amount/serde.rs +++ b/units/src/amount/serde.rs @@ -20,13 +20,16 @@ //! } //! ``` +#[cfg(feature = "alloc")] use core::fmt; use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[cfg(feature = "alloc")] // This is because `to_float_in` uses `to_string`. use super::Denomination; -use super::{Amount, ParseAmountError, SignedAmount}; +#[cfg(feature = "alloc")] +use super::ParseAmountError; +use super::{Amount, SignedAmount}; /// This trait is used only to avoid code duplication and naming collisions /// of the different serde serialization crates. @@ -37,6 +40,10 @@ pub trait SerdeAmount: Copy + Sized { fn ser_btc(self, s: S, _: private::Token) -> Result; #[cfg(feature = "alloc")] fn des_btc<'d, D: Deserializer<'d>>(d: D, _: private::Token) -> Result; + #[cfg(feature = "alloc")] + fn ser_str(self, s: S, _: private::Token) -> Result; + #[cfg(feature = "alloc")] + fn des_str<'d, D: Deserializer<'d>>(d: D, _: private::Token) -> Result; } mod private { @@ -50,8 +57,11 @@ pub trait SerdeAmountForOpt: Copy + Sized + SerdeAmount { fn ser_sat_opt(self, s: S, _: private::Token) -> Result; #[cfg(feature = "alloc")] fn ser_btc_opt(self, s: S, _: private::Token) -> Result; + #[cfg(feature = "alloc")] + fn ser_str_opt(self, s: S, _: private::Token) -> Result; } +#[cfg(feature = "alloc")] struct DisplayFullError(ParseAmountError); #[cfg(feature = "std")] @@ -70,6 +80,7 @@ impl fmt::Display for DisplayFullError { } #[cfg(not(feature = "std"))] +#[cfg(feature = "alloc")] impl fmt::Display for DisplayFullError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fmt::Display::fmt(&self.0, f) } } @@ -90,6 +101,18 @@ impl SerdeAmount for Amount { use serde::de::Error; Amount::from_btc(f64::deserialize(d)?).map_err(DisplayFullError).map_err(D::Error::custom) } + #[cfg(feature = "alloc")] + fn ser_str(self, s: S, _: private::Token) -> Result { + s.serialize_str(&self.to_string_in(Denomination::Bitcoin)) + } + #[cfg(feature = "alloc")] + fn des_str<'d, D: Deserializer<'d>>(d: D, _: private::Token) -> Result { + use serde::de::Error; + let s: alloc::string::String = Deserialize::deserialize(d)?; + Amount::from_str_in(&s, Denomination::Bitcoin) + .map_err(DisplayFullError) + .map_err(D::Error::custom) + } } impl SerdeAmountForOpt for Amount { @@ -101,6 +124,10 @@ impl SerdeAmountForOpt for Amount { fn ser_btc_opt(self, s: S, _: private::Token) -> Result { s.serialize_some(&self.to_btc()) } + #[cfg(feature = "alloc")] + fn ser_str_opt(self, s: S, _: private::Token) -> Result { + s.serialize_some(&self.to_string_in(Denomination::Bitcoin)) + } } impl SerdeAmount for SignedAmount { @@ -121,6 +148,18 @@ impl SerdeAmount for SignedAmount { .map_err(DisplayFullError) .map_err(D::Error::custom) } + #[cfg(feature = "alloc")] + fn ser_str(self, s: S, _: private::Token) -> Result { + s.serialize_str(self.to_string_in(Denomination::Bitcoin).as_str()) + } + #[cfg(feature = "alloc")] + fn des_str<'d, D: Deserializer<'d>>(d: D, _: private::Token) -> Result { + use serde::de::Error; + let s: alloc::string::String = Deserialize::deserialize(d)?; + SignedAmount::from_str_in(&s, Denomination::Bitcoin) + .map_err(DisplayFullError) + .map_err(D::Error::custom) + } } impl SerdeAmountForOpt for SignedAmount { @@ -132,6 +171,10 @@ impl SerdeAmountForOpt for SignedAmount { fn ser_btc_opt(self, s: S, _: private::Token) -> Result { s.serialize_some(&self.to_btc()) } + #[cfg(feature = "alloc")] + fn ser_str_opt(self, s: S, _: private::Token) -> Result { + s.serialize_some(&self.to_string_in(Denomination::Bitcoin)) + } } pub mod as_sat { @@ -272,3 +315,73 @@ pub mod as_btc { } } } + +#[cfg(feature = "alloc")] +pub mod as_str { + //! Serialize and deserialize [`Amount`](crate::Amount) as a JSON string denominated in BTC. + //! Use with `#[serde(with = "amount::serde::as_str")]`. + + use serde::{Deserializer, Serializer}; + + use super::private; + use crate::amount::serde::SerdeAmount; + + pub fn serialize(a: &A, s: S) -> Result { + a.ser_str(s, private::Token) + } + + pub fn deserialize<'d, A: SerdeAmount, D: Deserializer<'d>>(d: D) -> Result { + A::des_str(d, private::Token) + } + + pub mod opt { + //! Serialize and deserialize `Option` as a JSON string denominated in BTC. + //! Use with `#[serde(default, with = "amount::serde::as_str::opt")]`. + + use core::fmt; + use core::marker::PhantomData; + + use serde::{de, Deserializer, Serializer}; + + use super::private; + use crate::amount::serde::SerdeAmountForOpt; + + pub fn serialize( + a: &Option, + s: S, + ) -> Result { + match *a { + Some(a) => a.ser_str_opt(s, private::Token), + None => s.serialize_none(), + } + } + + pub fn deserialize<'d, A: SerdeAmountForOpt, D: Deserializer<'d>>( + d: D, + ) -> Result, D::Error> { + struct VisitOptAmt(PhantomData); + + impl<'de, X: SerdeAmountForOpt> de::Visitor<'de> for VisitOptAmt { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "An Option") + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(None) + } + fn visit_some(self, d: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Some(X::des_str(d, private::Token)?)) + } + } + d.deserialize_option(VisitOptAmt::(PhantomData)) + } + } +} diff --git a/units/src/amount/tests.rs b/units/src/amount/tests.rs index 0b22ea2ff..1213e888c 100644 --- a/units/src/amount/tests.rs +++ b/units/src/amount/tests.rs @@ -741,6 +741,31 @@ fn serde_as_btc() { assert!(t.unwrap_err().to_string().contains(&OutOfRangeError::negative().to_string())); } +#[cfg(feature = "serde")] +#[cfg(feature = "alloc")] +#[test] +fn serde_as_str() { + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct T { + #[serde(with = "crate::amount::serde::as_str")] + pub amt: Amount, + #[serde(with = "crate::amount::serde::as_str")] + pub samt: SignedAmount, + } + + serde_test::assert_tokens( + &T { amt: Amount::from_sat(123456789), samt: SignedAmount::from_sat(-123456789) }, + &[ + serde_test::Token::Struct { name: "T", len: 2 }, + serde_test::Token::String("amt"), + serde_test::Token::String("1.23456789"), + serde_test::Token::String("samt"), + serde_test::Token::String("-1.23456789"), + serde_test::Token::StructEnd, + ], + ); +} + #[cfg(feature = "serde")] #[cfg(feature = "alloc")] #[test] @@ -825,6 +850,49 @@ fn serde_as_sat_opt() { assert_eq!(without, serde_json::from_value(value_without).unwrap()); } +#[cfg(feature = "serde")] +#[cfg(feature = "alloc")] +#[test] +#[allow(clippy::inconsistent_digit_grouping)] // Group to show 100,000,000 sats per bitcoin. +fn serde_as_str_opt() { + use serde_json; + + #[derive(Serialize, Deserialize, PartialEq, Debug, Eq)] + struct T { + #[serde(default, with = "crate::amount::serde::as_str::opt")] + pub amt: Option, + #[serde(default, with = "crate::amount::serde::as_str::opt")] + pub samt: Option, + } + + let with = T { + amt: Some(Amount::from_sat(123456789)), + samt: Some(SignedAmount::from_sat(-123456789)), + }; + let without = T { amt: None, samt: None }; + + // Test Roundtripping + for s in [&with, &without].iter() { + let v = serde_json::to_string(s).unwrap(); + let w: T = serde_json::from_str(&v).unwrap(); + assert_eq!(w, **s); + } + + let t: T = + serde_json::from_str("{\"amt\": \"1.23456789\", \"samt\": \"-1.23456789\"}").unwrap(); + assert_eq!(t, with); + + let t: T = serde_json::from_str("{}").unwrap(); + assert_eq!(t, without); + + let value_with: serde_json::Value = + serde_json::from_str("{\"amt\": \"1.23456789\", \"samt\": \"-1.23456789\"}").unwrap(); + assert_eq!(with, serde_json::from_value(value_with).unwrap()); + + let value_without: serde_json::Value = serde_json::from_str("{}").unwrap(); + assert_eq!(without, serde_json::from_value(value_without).unwrap()); +} + #[test] fn sum_amounts() { assert_eq!(Amount::from_sat(0), [].iter().sum::());