diff --git a/backend/calc_functions/calc_func.py b/backend/calc_functions/calc_func.py index 7b52bee..8c5dfae 100644 --- a/backend/calc_functions/calc_func.py +++ b/backend/calc_functions/calc_func.py @@ -19,6 +19,13 @@ getcontext().prec = 50 # plenty for money math _INT_DEC_RE = re.compile(r'^[+-]?\d+$', re.ASCII) +_STRICT_DECIMAL_RE = re.compile( + r'^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$', re.ASCII +) + +_LOOKS_LIKE_SCI_RE = re.compile( + r'(^[+-]?\d+\.?\d*[eE])|(^[eE]\d+)', re.ASCII +) _CURVE_ORDER = SECP256k1.order _CURVE_GEN = SECP256k1.generator @@ -30,6 +37,22 @@ VerifyScriptWithTrace ) +try: + import bitcointx.util as _btx_util + if _btx_util._secp256k1_library_path is None: + import os as _os + _secp256k1_pkg = _os.path.dirname(secp256k1.__file__) + for _candidate in _os.listdir(_secp256k1_pkg): + if _candidate.startswith('_libsecp256k1') and ( + _candidate.endswith('.so') or _candidate.endswith('.dylib') + ): + _btx_util._secp256k1_library_path = _os.path.join( + _secp256k1_pkg, _candidate + ) + break +except Exception: + pass + from bitcointx.core.scripteval import ( # flag constants SCRIPT_VERIFY_P2SH, @@ -2032,8 +2055,8 @@ def _hasher(msg: bytes) -> bytes: amount_supplied = bool(amount_raw) if amount_supplied: try: + # Bitcoin amounts are always whole satoshis; reject any non-integer amount_param = int(amount_raw) - # Validate amount is non-negative if amount_param < 0: raise ValueError("Amount must be non-negative") except ValueError as e: @@ -2755,27 +2778,38 @@ def _parse_numeric_exact(raw: str): - decimal ints: '144', '+10', '-7' - hex: '0x90', '90' with A–F present (e.g. 'deadbeef') - decimal with fraction/exp: '12.5', '1e6', '0.1' + Raises ValueError for any input that does not fit a supported format + exactly, including NaN, Infinity, underscores, binary literals, trailing + dots, and malformed scientific-notation strings. """ s = str(raw).strip() if not s: raise ValueError("empty number") - # hex? - if s.lower().startswith("0x") or ( - all(c in "0123456789abcdefABCDEF" for c in s) - and any(c in "abcdefABCDEF" for c in s) - ): + # explicit hex prefix – always hex + if s.lower().startswith("0x"): return int(s, 16) - # plain integer? + if s.lower().startswith("0b"): + raise ValueError(f"'{raw}' is not a valid number") + + # plain integer if _INT_DEC_RE.fullmatch(s): return int(s, 10) - # decimal/exp → Decimal - try: + # decimal / fraction / scientific-notation → Decimal + if _STRICT_DECIMAL_RE.fullmatch(s): return Decimal(s) - except InvalidOperation: - raise ValueError(f"'{raw}' is not a valid number") + + # Ambiguous hex: all hex digits, at least one a–f letter, no recognised + if ( + all(c in "0123456789abcdefABCDEF" for c in s) + and any(c in "abcdefABCDEF" for c in s) + and not _LOOKS_LIKE_SCI_RE.search(s) + ): + return int(s, 16) + + raise ValueError(f"'{raw}' is not a valid number") def _coerce_for_op(a, b): """Promote to Decimal if either is Decimal; keep ints otherwise.""" diff --git a/backend/tests/test_calc_func.py b/backend/tests/test_calc_func.py index 72da372..cef0e46 100644 --- a/backend/tests/test_calc_func.py +++ b/backend/tests/test_calc_func.py @@ -846,6 +846,10 @@ def test_compare_equal_and_numeric_parsers(): assert calc._parse_numeric_exact("0x10") == 16 assert calc._parse_numeric_exact("10") == 10 assert calc._parse_numeric_exact("1.5") == Decimal("1.5") + assert calc._parse_numeric_exact("1e6") == Decimal("1e6") + assert calc._parse_numeric_exact("1e8") == Decimal("1e8") + assert calc._parse_numeric_exact("2.5e3") == Decimal("2.5e3") + assert calc._parse_numeric_exact("deadbeef") == 0xDEADBEEF with pytest.raises(ValueError): calc._parse_numeric_exact("") @@ -858,6 +862,8 @@ def test_compare_equal_and_numeric_parsers(): def test_compare_numbers_and_math_operations(): assert calc.compare_numbers(["10", "<", "20"]) == "true" assert calc.compare_numbers(["10", ">", "20"]) == "false" + assert calc.compare_numbers(["1e8", ">", "1000000"]) == "true" + assert calc.compare_numbers(["1e6", "<=", "1000000"]) == "true" with pytest.raises(ValueError): calc.compare_numbers(["10", "!=", "20"]) @@ -865,9 +871,49 @@ def test_compare_numbers_and_math_operations(): assert calc.math_operation(["10", "-", "5"]) == "5" assert calc.math_operation(["10", "*", "5"]) == "50" assert calc.math_operation(["3", "/", "2"]) == "1.5" + assert calc.math_operation(["1e8", "+", "1"]) == "100000001" + assert calc.math_operation(["1e6", "*", "2"]) == "2000000" with pytest.raises(ValueError): calc.math_operation(["1", "/", "0"]) +@pytest.mark.parametrize("raw", [ + "1e", "e10", "1E", "E10", "1e1e", + "0b10", "0B10", + # special Decimal values that must be rejected + "NaN", "nan", "Infinity", "-Infinity", "sNaN", + # underscore-separated numeric literals + "_10", "1__0", "1_", + # trailing dots – looks like a decimal but the fractional part is absent + "123.", "0.", + # malformed scientific notation: trailing dot before the exponent + "1.e6", "0.e3", +]) +def test_parse_numeric_exact_rejects_malformed(raw): + with pytest.raises(ValueError): + calc._parse_numeric_exact(raw) + + +@pytest.mark.parametrize("raw,expected", [ + ("fe1", 0xfe1), + ("a1e4", 0xa1e4), + ("b3e2f", 0xb3e2f), + (".5e3", Decimal(".5e3")), + ("+1e6", Decimal("+1e6")), + ("-0.5", Decimal("-0.5")), + ("0e0", Decimal("0e0")), + ("0X1F", 0x1F), + ("0xDEAD", 0xDEAD), +]) +def test_parse_numeric_exact_accepts_valid(raw, expected): + assert calc._parse_numeric_exact(raw) == expected + + +def test_parse_numeric_exact_malformed_propagates_to_api(): + with pytest.raises(ValueError): + calc.compare_numbers(["1e", "<", "31"]) + with pytest.raises(ValueError): + calc.math_operation(["e10", "+", "1"]) + def test_hash160_and_sha256_address_helpers(): p2pkh = calc.hash160_to_p2pkh_address(GENESIS_HASH160)