Reorganize project structure
This commit is contained in:
31
checkaddy_app/validators/__init__.py
Normal file
31
checkaddy_app/validators/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..constants import BCH, BSC, BTC, DASH, DOGE, ETH, LTC, POLYGON
|
||||
from .bch import validate_bch_address
|
||||
from .btc import validate_btc_address
|
||||
from .common import is_address_characters_safe
|
||||
from .dash import validate_dash_address
|
||||
from .doge import validate_doge_address
|
||||
from .evm import validate_evm_address
|
||||
from .ltc import validate_ltc_address
|
||||
|
||||
|
||||
def validate_address(coin: str, address: str) -> tuple[bool, str]:
|
||||
if not address:
|
||||
return False, "Address is required"
|
||||
if not is_address_characters_safe(address):
|
||||
return False, "Address contains unsupported characters"
|
||||
|
||||
if coin == BTC:
|
||||
return validate_btc_address(address)
|
||||
if coin == LTC:
|
||||
return validate_ltc_address(address)
|
||||
if coin == DOGE:
|
||||
return validate_doge_address(address)
|
||||
if coin == DASH:
|
||||
return validate_dash_address(address)
|
||||
if coin == BCH:
|
||||
return validate_bch_address(address)
|
||||
if coin in (ETH, BSC, POLYGON):
|
||||
return validate_evm_address(address)
|
||||
return False, "Unsupported coin"
|
||||
33
checkaddy_app/validators/bch.py
Normal file
33
checkaddy_app/validators/bch.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..constants import BCH_CASHADDR_RE
|
||||
from .common import base58check_verify
|
||||
|
||||
|
||||
def validate_bch_address(address: str) -> tuple[bool, str]:
|
||||
address = address.strip()
|
||||
lower = address.lower()
|
||||
if lower.startswith("bitcoincash:"):
|
||||
payload = lower.split(":", 1)[1]
|
||||
else:
|
||||
payload = lower
|
||||
|
||||
if payload.startswith("q") or payload.startswith("p"):
|
||||
if not BCH_CASHADDR_RE.fullmatch(payload):
|
||||
return False, "Invalid characters in BCH CashAddr payload"
|
||||
if len(payload) < 30:
|
||||
return False, "BCH CashAddr payload is too short"
|
||||
# Future improvement: replace this with full BCH CashAddr checksum verification.
|
||||
return True, "CashAddr format (checksum not verified)"
|
||||
|
||||
if address.startswith("1") or address.startswith("3"):
|
||||
valid, reason, version, payload_len = base58check_verify(address)
|
||||
if not valid:
|
||||
return False, reason
|
||||
if payload_len != 21:
|
||||
return False, "Unexpected Base58 payload length"
|
||||
if version not in (0x00, 0x05):
|
||||
return False, "Invalid BCH legacy version byte"
|
||||
return True, "Valid legacy Base58Check address"
|
||||
|
||||
return False, "BCH addresses must be CashAddr or legacy Base58"
|
||||
28
checkaddy_app/validators/btc.py
Normal file
28
checkaddy_app/validators/btc.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import base58check_verify, bech32_decode
|
||||
|
||||
|
||||
def validate_btc_address(address: str) -> tuple[bool, str]:
|
||||
address = address.strip()
|
||||
if address.lower().startswith("bc1"):
|
||||
hrp, data, spec = bech32_decode(address)
|
||||
if hrp is None:
|
||||
return False, "Invalid Bech32 or Bech32m checksum"
|
||||
if hrp != "bc":
|
||||
return False, "Invalid HRP for BTC"
|
||||
if not data:
|
||||
return False, "Missing witness program"
|
||||
return True, f"Valid {spec} address"
|
||||
|
||||
if not (address.startswith("1") or address.startswith("3")):
|
||||
return False, "BTC Base58 addresses must start with 1 or 3"
|
||||
|
||||
valid, reason, version, payload_len = base58check_verify(address)
|
||||
if not valid:
|
||||
return False, reason
|
||||
if payload_len != 21:
|
||||
return False, "Unexpected Base58 payload length"
|
||||
if version not in (0x00, 0x05):
|
||||
return False, "Invalid BTC version byte"
|
||||
return True, "Valid Base58Check address"
|
||||
85
checkaddy_app/validators/common.py
Normal file
85
checkaddy_app/validators/common.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from ..constants import ADDRESS_SAFE_RE, BASE58_INDEX, BECH32_CHARSET_MAP
|
||||
|
||||
|
||||
def is_address_characters_safe(address: str) -> bool:
|
||||
return ADDRESS_SAFE_RE.fullmatch(address) is not None
|
||||
|
||||
|
||||
def base58_decode(value: str) -> Optional[bytes]:
|
||||
number = 0
|
||||
for char in value:
|
||||
digit = BASE58_INDEX.get(char)
|
||||
if digit is None:
|
||||
return None
|
||||
number = number * 58 + digit
|
||||
|
||||
raw = b"" if number == 0 else number.to_bytes((number.bit_length() + 7) // 8, "big")
|
||||
pad = len(value) - len(value.lstrip("1"))
|
||||
return b"\x00" * pad + raw
|
||||
|
||||
|
||||
def base58check_verify(address: str) -> tuple[bool, str, Optional[int], Optional[int]]:
|
||||
decoded = base58_decode(address)
|
||||
if decoded is None:
|
||||
return False, "Invalid Base58 characters", None, None
|
||||
if len(decoded) < 4:
|
||||
return False, "Too short for Base58Check", None, None
|
||||
|
||||
payload, checksum = decoded[:-4], decoded[-4:]
|
||||
digest = hashlib.sha256(hashlib.sha256(payload).digest()).digest()
|
||||
if checksum != digest[:4]:
|
||||
return False, "Base58Check checksum mismatch", None, None
|
||||
if not payload:
|
||||
return False, "Missing version byte", None, None
|
||||
|
||||
return True, "Valid Base58Check", payload[0], len(payload)
|
||||
|
||||
|
||||
def bech32_polymod(values: list[int]) -> int:
|
||||
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
checksum = 1
|
||||
for value in values:
|
||||
top = checksum >> 25
|
||||
checksum = ((checksum & 0x1FFFFFF) << 5) ^ value
|
||||
for index in range(5):
|
||||
if (top >> index) & 1:
|
||||
checksum ^= generator[index]
|
||||
return checksum
|
||||
|
||||
|
||||
def bech32_hrp_expand(hrp: str) -> list[int]:
|
||||
return [ord(char) >> 5 for char in hrp] + [0] + [ord(char) & 31 for char in hrp]
|
||||
|
||||
|
||||
def bech32_verify_checksum(hrp: str, data: list[int], spec: str) -> bool:
|
||||
expected = 1 if spec == "bech32" else 0x2BC830A3
|
||||
return bech32_polymod(bech32_hrp_expand(hrp) + data) == expected
|
||||
|
||||
|
||||
def bech32_decode(address: str) -> tuple[Optional[str], Optional[list[int]], Optional[str]]:
|
||||
if address.lower() != address and address.upper() != address:
|
||||
return None, None, None
|
||||
|
||||
normalized = address.lower()
|
||||
separator_index = normalized.rfind("1")
|
||||
if separator_index < 1 or separator_index + 7 > len(normalized):
|
||||
return None, None, None
|
||||
|
||||
hrp = normalized[:separator_index]
|
||||
data: list[int] = []
|
||||
for char in normalized[separator_index + 1 :]:
|
||||
mapped = BECH32_CHARSET_MAP.get(char)
|
||||
if mapped is None:
|
||||
return None, None, None
|
||||
data.append(mapped)
|
||||
|
||||
if bech32_verify_checksum(hrp, data, "bech32"):
|
||||
return hrp, data[:-6], "bech32"
|
||||
if bech32_verify_checksum(hrp, data, "bech32m"):
|
||||
return hrp, data[:-6], "bech32m"
|
||||
return None, None, None
|
||||
18
checkaddy_app/validators/dash.py
Normal file
18
checkaddy_app/validators/dash.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import base58check_verify
|
||||
|
||||
|
||||
def validate_dash_address(address: str) -> tuple[bool, str]:
|
||||
address = address.strip()
|
||||
if not (address.startswith("X") or address.startswith("7")):
|
||||
return False, "DASH Base58 addresses must start with X or 7"
|
||||
|
||||
valid, reason, version, payload_len = base58check_verify(address)
|
||||
if not valid:
|
||||
return False, reason
|
||||
if payload_len != 21:
|
||||
return False, "Unexpected Base58 payload length"
|
||||
if version not in (0x4C, 0x10):
|
||||
return False, "Invalid DASH version byte"
|
||||
return True, "Valid Base58Check address"
|
||||
18
checkaddy_app/validators/doge.py
Normal file
18
checkaddy_app/validators/doge.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import base58check_verify
|
||||
|
||||
|
||||
def validate_doge_address(address: str) -> tuple[bool, str]:
|
||||
address = address.strip()
|
||||
if not (address.startswith("D") or address.startswith("A") or address.startswith("9")):
|
||||
return False, "DOGE Base58 addresses must start with D, A, or 9"
|
||||
|
||||
valid, reason, version, payload_len = base58check_verify(address)
|
||||
if not valid:
|
||||
return False, reason
|
||||
if payload_len != 21:
|
||||
return False, "Unexpected Base58 payload length"
|
||||
if version not in (0x1E, 0x16):
|
||||
return False, "Invalid DOGE version byte"
|
||||
return True, "Valid Base58Check address"
|
||||
14
checkaddy_app/validators/evm.py
Normal file
14
checkaddy_app/validators/evm.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..constants import EVM_ADDRESS_RE
|
||||
|
||||
|
||||
def validate_evm_address(address: str) -> tuple[bool, str]:
|
||||
address = address.strip()
|
||||
if not EVM_ADDRESS_RE.fullmatch(address):
|
||||
return False, "EVM address must match 0x + 40 hex characters"
|
||||
|
||||
body = address[2:]
|
||||
if body.islower() or body.isupper():
|
||||
return True, "Valid EVM hex address"
|
||||
return True, "Valid mixed-case EVM address (checksum not verified)"
|
||||
28
checkaddy_app/validators/ltc.py
Normal file
28
checkaddy_app/validators/ltc.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import base58check_verify, bech32_decode
|
||||
|
||||
|
||||
def validate_ltc_address(address: str) -> tuple[bool, str]:
|
||||
address = address.strip()
|
||||
if address.lower().startswith("ltc1"):
|
||||
hrp, data, spec = bech32_decode(address)
|
||||
if hrp is None:
|
||||
return False, "Invalid Bech32 or Bech32m checksum"
|
||||
if hrp != "ltc":
|
||||
return False, "Invalid HRP for LTC"
|
||||
if not data:
|
||||
return False, "Missing witness program"
|
||||
return True, f"Valid {spec} address"
|
||||
|
||||
if not (address.startswith("L") or address.startswith("M") or address.startswith("3")):
|
||||
return False, "LTC Base58 addresses must start with L, M, or 3"
|
||||
|
||||
valid, reason, version, payload_len = base58check_verify(address)
|
||||
if not valid:
|
||||
return False, reason
|
||||
if payload_len != 21:
|
||||
return False, "Unexpected Base58 payload length"
|
||||
if version not in (0x30, 0x32, 0x05):
|
||||
return False, "Invalid LTC version byte"
|
||||
return True, "Valid Base58Check address"
|
||||
Reference in New Issue
Block a user