Merge rust-bitcoin/rust-bitcoin#6893: units: Reject malformed amount strings
What changed, and why it matters
This update fixes a bug in how the library reads Bitcoin amount strings like '1.5 BTC'. Previously, certain malformed inputs such as '.', '._', '1_', '1_.0', and '1._0' were incorrectly accepted and treated as valid amounts (often zero), instead of being rejected as errors. The fix now requires at least one digit and only allows underscore separators between two digits, matching Rust's numeric literal rules. This prevents silent misinterpretation of invalid amount strings.
Review any code that parses Bitcoin amount strings from untrusted input and ensure the updated library version is used. Validate that downstream applications do not rely on the previously accepted malformed formats. Consider adding integration tests for amount parsing at application boundaries.
Security signals we found
Input validation bypass in amount parser
Malformed strings silently parsed as zero or ordinary amounts
Underscore separator placement not enforced
Missing digit requirement in numeric parser
Silent misinterpretation of invalid amount strings
Evidence from the diff
The parse_signed_to_satoshi function in units/src/amount/mod.rs previously accepted malformed amount strings because it did not require at least one digit and did not enforce correct underscore placement. The patch introduces a PrevChar state tracker and rejects inputs consisting only of a dot (‘.’ or ‘-.’), multiple decimal dots, underscores adjacent to the decimal point, leading underscores, consecutive underscores, and trailing underscores. Error handling is extended with a new MissingDigitsKind::OnlyDot variant. Tests are added to enforce rejection of malformed inputs and an existing round-trip test relying on the old invalid format is corrected.
Changed components
units/src/amount/mod.rsunits/src/amount/error.rsunits/src/amount/tests.rsAmount::from_str_inSignedAmount::from_str_inparse_signed_to_satoshiInspect captured patch +88 / −34
### units/src/amount/error.rs
@@ -234,7 +234,8 @@ impl std::error::Error for TooPreciseError {
/// Error returned when digits were expected in the input but there were none.
///
-/// In particular, this is currently returned when the string is empty or only contains the minus sign.
+/// In particular, this is currently returned when the string is empty, only contains the minus
+/// sign, or only contains a dot (for example `"."` or `"-."`).
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct MissingDigitsError {
pub(super) kind: MissingDigitsKind,
@@ -252,6 +253,10 @@ impl fmt::Display for MissingDigitsError {
MissingDigitsKind::Empty => f.write_str("the input is empty"),
MissingDigitsKind::OnlyMinusSign =>
f.write_str("there are no digits following the minus (-) sign"),
+ MissingDigitsKind::OnlyDot { with_minus_sign: false } =>
+ f.write_str("the input only contains the dot character"),
+ MissingDigitsKind::OnlyDot { with_minus_sign: true } =>
+ f.write_str("the input only contains the minus sign and the dot character"),
}
}
}
@@ -269,6 +274,7 @@ impl std::error::Error for MissingDigitsError {
pub(super) enum MissingDigitsKind {
Empty,
OnlyMinusSign,
+ OnlyDot { with_minus_sign: bool },
}
/// Error returned when the input contains an invalid character.
### units/src/amount/mod.rs
@@ -258,6 +258,14 @@ fn parse_signed_to_satoshi(
mut s: &str,
denom: Denomination,
) -> Result<(bool, SignedAmount), InnerParseError> {
+ /// The previously parsed character, used to validate the position of underscores.
+ #[derive(Clone, Copy)]
+ enum PrevChar {
+ Digit,
+ Underscore,
+ Dot,
+ }
+
if s.is_empty() {
return Err(MissingDigitsError { kind: MissingDigitsKind::Empty })
.map_err(InnerParseError::MissingDigits);
@@ -272,6 +280,15 @@ fn parse_signed_to_satoshi(
s = &s[1..];
}
+ // Inputs of `.` and `-.` are invalid.
+ // `-.` is reassigned above to `.` with `is_negative = true`.
+ if s == "." {
+ return Err(MissingDigitsError {
+ kind: MissingDigitsKind::OnlyDot { with_minus_sign: is_negative },
+ })
+ .map_err(InnerParseError::MissingDigits);
+ }
+
let max_decimals = {
// The difference in precision between native (satoshi)
// and desired denomination.
@@ -300,12 +317,26 @@ fn parse_signed_to_satoshi(
};
let mut decimals = None;
- // The number of consecutive underscores
- let mut underscores = None;
+ let mut prev_char: Option<PrevChar> = None;
let mut value: i64 = 0; // as satoshis
for (i, c) in s.char_indices() {
- match c {
- '0'..='9' => {
+ match (c, prev_char) {
+ // More than one decimal dot is invalid.
+ ('.', _) if decimals.is_some() =>
+ return Err(InvalidCharacterError {
+ invalid_char: '.',
+ position: i + usize::from(is_negative),
+ })
+ .map_err(InnerParseError::InvalidCharacter),
+ // Underscores immediately before the decimal separator are invalid.
+ ('.', Some(PrevChar::Underscore)) =>
+ return Err(BadPositionError {
+ char: '_',
+ position: (i - 1) + usize::from(is_negative),
+ })
+ .map_err(InnerParseError::BadPosition),
+ // A valid digit.
+ ('0'..='9', _) => {
// Do `value = 10 * value + digit`, catching overflows.
match 10_i64.checked_mul(value) {
None => return Err(InnerParseError::Overflow { is_negative }),
@@ -322,37 +353,23 @@ fn parse_signed_to_satoshi(
return Err(TooPreciseError { position: i + usize::from(is_negative) })
.map_err(InnerParseError::TooPrecise),
};
- underscores = None;
+ prev_char = Some(PrevChar::Digit);
}
- '_' if i == 0 =>
- // Leading underscore
+ // A valid underscore must follow a digit.
+ ('_', Some(PrevChar::Digit)) => prev_char = Some(PrevChar::Underscore),
+ // Underscores not after a digit, i.e. '._', '__' and leading '_', are invalid.
+ ('_', _) =>
return Err(BadPositionError { char: '_', position: i + usize::from(is_negative) })
.map_err(InnerParseError::BadPosition),
- '_' => match underscores {
- None => underscores = Some(1),
- // Consecutive underscores
- _ =>
- return Err(BadPositionError {
- char: '_',
- position: i + usize::from(is_negative),
- })
- .map_err(InnerParseError::BadPosition),
- },
- '.' => match decimals {
- None if max_decimals <= 0 => break,
- None => {
- decimals = Some(0);
- underscores = None;
- }
- // Double decimal dot.
- _ =>
- return Err(InvalidCharacterError {
- invalid_char: '.',
- position: i + usize::from(is_negative),
- })
- .map_err(InnerParseError::InvalidCharacter),
- },
- c =>
+ // A decimal dot with a denomination that does not allow decimals is invalid.
+ ('.', _) if decimals.is_none() && max_decimals <= 0 => break,
+ // A valid decimal dot.
+ ('.', _) if decimals.is_none() => {
+ decimals = Some(0);
+ prev_char = Some(PrevChar::Dot);
+ }
+ // Any character that does not match an above digit, dot or underscore arm is invalid.
+ (c, _) =>
return Err(InvalidCharacterError {
invalid_char: c,
position: i + usize::from(is_negative),
@@ -361,6 +378,15 @@ fn parse_signed_to_satoshi(
}
}
+ // The last character must not be an underscore.
+ if matches!(prev_char, Some(PrevChar::Underscore)) {
+ return Err(BadPositionError {
+ char: '_',
+ position: (s.len() - 1) + usize::from(is_negative),
+ })
+ .map_err(InnerParseError::BadPosition);
+ }
+
// Decimally shift left by `max_decimals - decimals`.
let scale_factor = max_decimals - decimals.unwrap_or(0);
for _ in 0..scale_factor {
### units/src/amount/tests.rs
@@ -492,6 +492,18 @@ fn parsing() {
kind: MissingDigitsKind::OnlyMinusSign
})))
);
+ assert_eq!(
+ p(".", den_btc),
+ Err(amt_err(ParseAmountErrorInner::MissingDigits(MissingDigitsError {
+ kind: MissingDigitsKind::OnlyDot { with_minus_sign: false }
+ })))
+ );
+ assert_eq!(
+ sp("-.", den_btc),
+ Err(amt_err(ParseAmountErrorInner::MissingDigits(MissingDigitsError {
+ kind: MissingDigitsKind::OnlyDot { with_minus_sign: true }
+ })))
+ );
assert_eq!(
p("-1.0x", den_btc),
Err(amt_err(ParseAmountErrorInner::InvalidCharacter(InvalidCharacterError {
@@ -560,6 +572,16 @@ fn parsing() {
);
}
+#[test]
+fn parsing_rejects_malformed_numeric_separators() {
+ use super::Denomination as D;
+
+ for input in [".", "._", "0_", "1_", "1_.0", "1._0"] {
+ assert!(Amount::from_str_in(input, D::Bitcoin).is_err(), "accepted {input:?}");
+ assert!(SignedAmount::from_str_in(input, D::Bitcoin).is_err(), "accepted {input:?}");
+ }
+}
+
#[test]
#[cfg(feature = "alloc")]
fn to_string() {
@@ -915,7 +937,7 @@ fn from_str() {
ok_scase("-21000000 BTC", SignedAmount::MIN);
ok_case("1_000 sat", sat(1000));
ok_case("1_0_0_0_0_0_0 satoshi", sat(1_000_000));
- ok_scase("-0_._0_10_00 BTC", ssat(-1_000_000));
+ ok_scase("-0.0_10_00 BTC", ssat(-1_000_000));
}
#[test]Why this scored 52/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.