194 lines
5.7 KiB
Python
194 lines
5.7 KiB
Python
"""
|
|
bal.core.input_rules
|
|
====================
|
|
|
|
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
|
|
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
|
|
amount field normalisation.
|
|
|
|
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
|
|
Keeping them here makes them testable without Qt and lets any GUI front-end
|
|
reuse the exact same parsing rules.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Any, Optional, Tuple, Union
|
|
|
|
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
|
|
|
__all__ = [
|
|
"NLOCKTIME_BLOCKHEIGHT_MAX",
|
|
"NLOCKTIME_MAX",
|
|
"NLOCKTIME_MIN",
|
|
"LockTimeEditor",
|
|
"normalize_locktime_raw_text",
|
|
"normalize_perc_amount_text",
|
|
"parse_perc_amount",
|
|
"replace_dy_suffixes",
|
|
]
|
|
|
|
|
|
class LockTimeEditor:
|
|
"""Acceptance bounds shared by the RAW and Date locktime editors.
|
|
|
|
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
|
|
live here so they can be tested and reused without Qt. Widget subclasses
|
|
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
|
|
"""
|
|
|
|
min_allowed_value = NLOCKTIME_MIN
|
|
max_allowed_value = NLOCKTIME_MAX
|
|
alarm = None
|
|
|
|
def get_value(self) -> Optional[int]:
|
|
raise NotImplementedError()
|
|
|
|
def set_value(self, x: Any, force=True) -> None:
|
|
raise NotImplementedError()
|
|
|
|
@classmethod
|
|
def is_acceptable_locktime(cls, x: Any) -> bool:
|
|
"""True when ``x`` (string or int) is within the allowed locktime bounds.
|
|
|
|
An empty/falsy value is accepted (the field is not yet filled in).
|
|
"""
|
|
if not x: # e.g. empty string
|
|
return True
|
|
try:
|
|
x = int(x)
|
|
except Exception as _e:
|
|
return False
|
|
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
|
|
|
@staticmethod
|
|
def get_max_allowed_timestamp() -> int:
|
|
"""Highest locktime timestamp accepted on this platform.
|
|
|
|
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
|
|
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
|
|
"""
|
|
ts = NLOCKTIME_MAX
|
|
# Test if this value is within the valid timestamp limits (which is
|
|
# platform-dependent). see #6170
|
|
try:
|
|
datetime.fromtimestamp(ts)
|
|
except (OSError, OverflowError):
|
|
ts = 2**31 - 1 # INT32_MAX
|
|
datetime.fromtimestamp(ts) # test if raises
|
|
return ts
|
|
|
|
|
|
def replace_dy_suffixes(text: str) -> str:
|
|
"""Strip the relative-time suffixes (d/y) from ``text``.
|
|
|
|
Only days ("d") and years ("y") are supported. The block-height suffix
|
|
("b") was removed (A1): locktimes are always timestamps now.
|
|
"""
|
|
return str(text).replace("d", "").replace("y", "")
|
|
|
|
|
|
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
|
|
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
|
|
|
|
When the character just before ``pos`` equals ``appendix``, the text is
|
|
re-normalised so only one suffix remains.
|
|
"""
|
|
try:
|
|
charpos = pos - 1
|
|
charpos = max(0, charpos)
|
|
charpos = min(len(s) - 1, charpos)
|
|
if appendix == s[charpos]:
|
|
s = replace_dy_suffixes(s) + appendix
|
|
pos = charpos
|
|
except Exception:
|
|
pass
|
|
return pos, s
|
|
|
|
|
|
def normalize_locktime_raw_text(
|
|
text: str, pos: int
|
|
) -> Tuple[str, bool, bool, int]:
|
|
"""Sanitise the RAW locktime field text.
|
|
|
|
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
|
|
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
|
|
|
|
Args:
|
|
text: the raw field text.
|
|
pos: the cursor position within ``text``.
|
|
|
|
Returns:
|
|
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
|
text and ``new_pos`` the adjusted cursor position.
|
|
"""
|
|
text = text.strip()
|
|
chars = "0123456789dy"
|
|
pos = len("".join([i for i in text[:pos] if i in chars]))
|
|
s = "".join([i for i in text if i in chars])
|
|
isdays = False
|
|
isyears = False
|
|
|
|
pos, s = _checkbdy(s, pos, "d")
|
|
pos, s = _checkbdy(s, pos, "y")
|
|
|
|
if "d" in s:
|
|
isdays = True
|
|
if "y" in s:
|
|
isyears = True
|
|
|
|
if isdays:
|
|
s = replace_dy_suffixes(s) + "d"
|
|
if isyears:
|
|
s = replace_dy_suffixes(s) + "y"
|
|
return s, isdays, isyears, pos
|
|
|
|
|
|
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
|
|
"""Sanitise the amount-or-percentage field text.
|
|
|
|
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
|
|
as a percentage (``is_perc``). At most 8 decimal digits after the point.
|
|
|
|
Args:
|
|
text: the raw field text.
|
|
decimal_point: the decimal separator character (``electrum`` uses
|
|
``DECIMAL_POINT``, locale dependent).
|
|
|
|
Returns:
|
|
``(clean, is_perc)``.
|
|
"""
|
|
text = text.strip()
|
|
chars = "0123456789%"
|
|
chars += decimal_point
|
|
|
|
s = "".join([i for i in text if i in chars])
|
|
|
|
if "%" in s:
|
|
is_perc = True
|
|
s = s.replace("%", "")
|
|
else:
|
|
is_perc = False
|
|
|
|
if decimal_point in s:
|
|
p = s.find(decimal_point)
|
|
s = s.replace(decimal_point, "")
|
|
s = s[:p] + decimal_point + s[p : p + 8]
|
|
if is_perc:
|
|
s += "%"
|
|
|
|
return s, is_perc
|
|
|
|
|
|
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
|
|
"""Parse an amount-or-percentage field text into a numeric value.
|
|
|
|
Returns ``None`` when the text cannot be parsed.
|
|
"""
|
|
try:
|
|
text = text.replace(decimal_point, ".")
|
|
text = text.replace("%", "")
|
|
return Decimal(text)
|
|
except Exception:
|
|
return None
|