Merge rust-bitcoin/rust-bitcoin#6954: units: serialize unsigned amounts as u64
What changed, and why it matters
This commit fixes a mismatch in how unsigned Bitcoin amounts were serialized versus deserialized when using certain compact binary formats. Previously, an unsigned amount (like 100 satoshis) was written as a signed number, which caused formats such as postcard/bincode with varint encoding to silently double the value on read-back and to reject very large amounts near the Bitcoin supply cap. The fix makes serialization use the same unsigned 64-bit hint that deserialization already expected, so round-trips now produce the same value.
Review any persisted binary serde data produced by `Amount`/`as_sat` serialization in varint formats (bincode, postcard, etc.) before this fix; such data may decode to doubled values or fail validation. Upgrade to the patched version for new data and consider migration/re-encoding of existing stored amounts.
Security signals we found
Data integrity bug: serialized values decode to different numeric values in varint binary formats
Range-check failure: Amount::MAX and large values near the cap fail deserialization after round-trip
Serde serialize/deserialize hint mismatch for unsigned amount types
Fix includes regression test for varint round-trip and token-type expectations
Evidence from the diff
The as_sat serde module in units/src/amount/serde.rs previously serialized all amounts through SignedAmount::to_sat() as i64, while deserialization for Amount hinted u64. For varint encodings (e.g., bincode/postcard), signed integers use zigzag encoding, so positive values are stored as n * 2. A decoder that expects u64 therefore reads the zigzag-encoded positive i64 as a raw u64, doubling the value. The patch branches serialization on is_signed::<A>(), emitting u64 for unsigned amount types and i64 for signed ones, and updates tests to expect U64 tokens for unsigned amounts and adds a varint round-trip test.
Changed components
units/src/amount/serde.rsunits/tests/serde.rsAmount serde serialization via `as_sat`Optional and vector `as_sat` serializersInspect captured patch +66 / −11
### units/src/amount/serde.rs
@@ -80,10 +80,14 @@ pub mod as_sat {
#[inline]
pub fn serialize<A, S: Serializer>(a: &A, s: S) -> Result<S::Ok, S::Error>
where
- A: Into<SignedAmount> + Copy,
+ A: Into<SignedAmount> + TryFrom<SignedAmount> + Copy,
{
- let amount: SignedAmount = (*a).into();
- i64::serialize(&amount.to_sat(), s)
+ let sat = (*a).into().to_sat();
+ if is_signed::<A>() {
+ i64::serialize(&sat, s)
+ } else {
+ u64::serialize(&(sat as u64), s)
+ }
}
#[inline]
@@ -168,9 +172,14 @@ pub mod as_sat {
#[allow(clippy::ref_option)] // API forced by serde.
pub fn serialize<A, S: Serializer>(a: &Option<A>, s: S) -> Result<S::Ok, S::Error>
where
- A: Into<SignedAmount> + Copy,
+ A: Into<SignedAmount> + TryFrom<SignedAmount> + Copy,
{
- a.map(Into::into).map(SignedAmount::to_sat).serialize(s)
+ let sat = a.map(Into::into).map(SignedAmount::to_sat);
+ if is_signed::<A>() {
+ sat.serialize(s)
+ } else {
+ sat.map(|sat| sat as u64).serialize(s)
+ }
}
pub fn deserialize<'d, A, D: Deserializer<'d>>(d: D) -> Result<Option<A>, D::Error>
@@ -242,9 +251,14 @@ pub mod as_sat {
#[inline]
pub fn serialize<A, S: Serializer>(a: &[A], s: S) -> Result<S::Ok, S::Error>
where
- A: Into<SignedAmount> + Copy,
+ A: Into<SignedAmount> + TryFrom<SignedAmount> + Copy,
{
- s.collect_seq(a.iter().map(|&amount| amount.into()).map(SignedAmount::to_sat))
+ let sats = a.iter().map(|&amount| amount.into()).map(SignedAmount::to_sat);
+ if is_signed::<A>() {
+ s.collect_seq(sats)
+ } else {
+ s.collect_seq(sats.map(|sat| sat as u64))
+ }
}
pub fn deserialize<'d, A, D: Deserializer<'d>>(d: D) -> Result<Vec<A>, D::Error>
### units/tests/serde.rs
@@ -115,6 +115,28 @@ fn sat(sat: u64) -> Amount { Amount::from_sat(sat).unwrap() }
#[track_caller]
fn ssat(ssat: i64) -> SignedAmount { SignedAmount::from_sat(ssat).unwrap() }
+#[test]
+fn serde_amount_as_sat_varint_round_trip() {
+ // Other tests use JSON and serde_test, which carry the type with
+ // the value and ignore the deserialize hint.
+ // Varint bincode bytes do not say if a number is signed, so the
+ // deserialize hint decides how they get decoded.
+ use bincode::Options as _;
+
+ #[derive(Serialize, Deserialize, PartialEq, Debug)]
+ struct T {
+ #[serde(with = "crate::amount::serde::as_sat")]
+ pub amt: Amount,
+ }
+
+ let opts = || bincode::DefaultOptions::new().with_varint_encoding();
+ for amt in [Amount::ZERO, Amount::ONE_SAT, Amount::MAX] {
+ let t = T { amt };
+ let bytes = opts().serialize(&t).unwrap();
+ assert_eq!(opts().deserialize::<T>(&bytes).unwrap(), t);
+ }
+}
+
#[test]
#[cfg(feature = "serde")]
fn serde_amount_as_sat() {
@@ -131,14 +153,33 @@ fn serde_amount_as_sat() {
&[
serde_test::Token::Struct { name: "T", len: 2 },
serde_test::Token::Str("amt"),
- serde_test::Token::I64(123_456_789),
+ serde_test::Token::U64(123_456_789),
serde_test::Token::Str("samt"),
serde_test::Token::I64(-123_456_789),
serde_test::Token::StructEnd,
],
);
}
+#[test]
+fn serde_amount_as_sat_accepts_positive_i64() {
+ #[derive(Deserialize, PartialEq, Debug)]
+ struct T {
+ #[serde(with = "crate::amount::serde::as_sat")]
+ pub amt: Amount,
+ }
+
+ serde_test::assert_de_tokens(
+ &T { amt: sat(123_456_789) },
+ &[
+ serde_test::Token::Struct { name: "T", len: 1 },
+ serde_test::Token::Str("amt"),
+ serde_test::Token::I64(123_456_789),
+ serde_test::Token::StructEnd,
+ ],
+ );
+}
+
#[test]
#[cfg(feature = "serde")]
#[cfg(feature = "alloc")]
@@ -160,9 +201,9 @@ fn serde_amount_as_sat_vec() {
serde_test::Token::Struct { name: "T", len: 2 },
serde_test::Token::Str("amt"),
serde_test::Token::Seq { len: Some(3) },
- serde_test::Token::I64(123),
- serde_test::Token::I64(456),
- serde_test::Token::I64(789),
+ serde_test::Token::U64(123),
+ serde_test::Token::U64(456),
+ serde_test::Token::U64(789),
serde_test::Token::SeqEnd,
serde_test::Token::Str("samt"),
serde_test::Token::Seq { len: Some(3) },Why this scored 62/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.