Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/calc_functions/calc_func.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
46 changes: 46 additions & 0 deletions backend/tests/test_calc_func.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("")

Expand All @@ -858,16 +862,58 @@ 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"])

assert calc.math_operation(["10", "+", "5"]) == "15"
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)
Expand Down