Skip to content

Commit a57ffaf

Browse files
fix: parse scientific numeric notation strictly (#13)
Fixes #10.
1 parent 74d91bb commit a57ffaf

2 files changed

Lines changed: 76 additions & 11 deletions

File tree

backend/calc_functions/calc_func.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@
2222
getcontext().prec = 50 # plenty for money math
2323

2424
_INT_DEC_RE = re.compile(r'^[+-]?\d+$', re.ASCII)
25+
_STRICT_DECIMAL_RE = re.compile(
26+
r'^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$', re.ASCII
27+
)
28+
29+
_LOOKS_LIKE_SCI_RE = re.compile(
30+
r'(^[+-]?\d+\.?\d*[eE])|(^[eE]\d+)', re.ASCII
31+
)
32+
2533
_UINT_DEC_NO_LEADING_ZERO_RE = re.compile(r"^(0|[1-9]\d*)$", re.ASCII)
2634

2735
_CURVE_ORDER = SECP256k1.order
@@ -2745,8 +2753,8 @@ def _hasher(msg: bytes) -> bytes:
27452753
amount_supplied = bool(amount_raw)
27462754
if amount_supplied:
27472755
try:
2756+
# Bitcoin amounts are always whole satoshis; reject any non-integer
27482757
amount_param = int(amount_raw)
2749-
# Validate amount is non-negative
27502758
if amount_param < 0:
27512759
raise ValueError("Amount must be non-negative")
27522760
except ValueError as e:
@@ -3468,27 +3476,38 @@ def _parse_numeric_exact(raw: str):
34683476
- decimal ints: '144', '+10', '-7'
34693477
- hex: '0x90', '90' with A–F present (e.g. 'deadbeef')
34703478
- decimal with fraction/exp: '12.5', '1e6', '0.1'
3479+
Raises ValueError for any input that does not fit a supported format
3480+
exactly, including NaN, Infinity, underscores, binary literals, trailing
3481+
dots, and malformed scientific-notation strings.
34713482
"""
34723483
s = str(raw).strip()
34733484
if not s:
34743485
raise ValueError("empty number")
34753486

3476-
# hex?
3477-
if s.lower().startswith("0x") or (
3478-
all(c in "0123456789abcdefABCDEF" for c in s)
3479-
and any(c in "abcdefABCDEF" for c in s)
3480-
):
3487+
# explicit hex prefix – always hex
3488+
if s.lower().startswith("0x"):
34813489
return int(s, 16)
34823490

3483-
# plain integer?
3491+
if s.lower().startswith("0b"):
3492+
raise ValueError(f"'{raw}' is not a valid number")
3493+
3494+
# plain integer
34843495
if _INT_DEC_RE.fullmatch(s):
34853496
return int(s, 10)
34863497

3487-
# decimal/exp → Decimal
3488-
try:
3498+
# decimal / fraction / scientific-notation → Decimal
3499+
if _STRICT_DECIMAL_RE.fullmatch(s):
34893500
return Decimal(s)
3490-
except InvalidOperation:
3491-
raise ValueError(f"'{raw}' is not a valid number")
3501+
3502+
# Ambiguous hex: all hex digits, at least one a–f letter, no recognised
3503+
if (
3504+
all(c in "0123456789abcdefABCDEF" for c in s)
3505+
and any(c in "abcdefABCDEF" for c in s)
3506+
and not _LOOKS_LIKE_SCI_RE.search(s)
3507+
):
3508+
return int(s, 16)
3509+
3510+
raise ValueError(f"'{raw}' is not a valid number")
34923511

34933512
def _coerce_for_op(a, b):
34943513
"""Promote to Decimal if either is Decimal; keep ints otherwise."""

backend/tests/test_calc_func.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,10 @@ def test_compare_equal_and_numeric_parsers():
11201120
assert calc._parse_numeric_exact("0x10") == 16
11211121
assert calc._parse_numeric_exact("10") == 10
11221122
assert calc._parse_numeric_exact("1.5") == Decimal("1.5")
1123+
assert calc._parse_numeric_exact("1e6") == Decimal("1e6")
1124+
assert calc._parse_numeric_exact("1e8") == Decimal("1e8")
1125+
assert calc._parse_numeric_exact("2.5e3") == Decimal("2.5e3")
1126+
assert calc._parse_numeric_exact("deadbeef") == 0xDEADBEEF
11231127
with pytest.raises(ValueError):
11241128
calc._parse_numeric_exact("")
11251129

@@ -1132,16 +1136,58 @@ def test_compare_equal_and_numeric_parsers():
11321136
def test_compare_numbers_and_math_operations():
11331137
assert calc.compare_numbers(["10", "<", "20"]) == "true"
11341138
assert calc.compare_numbers(["10", ">", "20"]) == "false"
1139+
assert calc.compare_numbers(["1e8", ">", "1000000"]) == "true"
1140+
assert calc.compare_numbers(["1e6", "<=", "1000000"]) == "true"
11351141
with pytest.raises(ValueError):
11361142
calc.compare_numbers(["10", "!=", "20"])
11371143

11381144
assert calc.math_operation(["10", "+", "5"]) == "15"
11391145
assert calc.math_operation(["10", "-", "5"]) == "5"
11401146
assert calc.math_operation(["10", "*", "5"]) == "50"
11411147
assert calc.math_operation(["3", "/", "2"]) == "1.5"
1148+
assert calc.math_operation(["1e8", "+", "1"]) == "100000001"
1149+
assert calc.math_operation(["1e6", "*", "2"]) == "2000000"
11421150
with pytest.raises(ValueError):
11431151
calc.math_operation(["1", "/", "0"])
11441152

1153+
@pytest.mark.parametrize("raw", [
1154+
"1e", "e10", "1E", "E10", "1e1e",
1155+
"0b10", "0B10",
1156+
# special Decimal values that must be rejected
1157+
"NaN", "nan", "Infinity", "-Infinity", "sNaN",
1158+
# underscore-separated numeric literals
1159+
"_10", "1__0", "1_",
1160+
# trailing dots – looks like a decimal but the fractional part is absent
1161+
"123.", "0.",
1162+
# malformed scientific notation: trailing dot before the exponent
1163+
"1.e6", "0.e3",
1164+
])
1165+
def test_parse_numeric_exact_rejects_malformed(raw):
1166+
with pytest.raises(ValueError):
1167+
calc._parse_numeric_exact(raw)
1168+
1169+
1170+
@pytest.mark.parametrize("raw,expected", [
1171+
("fe1", 0xfe1),
1172+
("a1e4", 0xa1e4),
1173+
("b3e2f", 0xb3e2f),
1174+
(".5e3", Decimal(".5e3")),
1175+
("+1e6", Decimal("+1e6")),
1176+
("-0.5", Decimal("-0.5")),
1177+
("0e0", Decimal("0e0")),
1178+
("0X1F", 0x1F),
1179+
("0xDEAD", 0xDEAD),
1180+
])
1181+
def test_parse_numeric_exact_accepts_valid(raw, expected):
1182+
assert calc._parse_numeric_exact(raw) == expected
1183+
1184+
1185+
def test_parse_numeric_exact_malformed_propagates_to_api():
1186+
with pytest.raises(ValueError):
1187+
calc.compare_numbers(["1e", "<", "31"])
1188+
with pytest.raises(ValueError):
1189+
calc.math_operation(["e10", "+", "1"])
1190+
11451191

11461192
def test_hash160_and_sha256_address_helpers():
11471193
p2pkh = calc.hash160_to_p2pkh_address(GENESIS_HASH160)

0 commit comments

Comments
 (0)