diff --git a/hdwallet/cryptocurrencies/__init__.py b/hdwallet/cryptocurrencies/__init__.py index cc9ce945..7c94e2ee 100644 --- a/hdwallet/cryptocurrencies/__init__.py +++ b/hdwallet/cryptocurrencies/__init__.py @@ -32,6 +32,7 @@ from .beetlecoin import BeetleCoin from .belacoin import BelaCoin from .binance import Binance +from .bismuth import Bismuth from .bitcloud import BitCloud from .bitcoin import Bitcoin from .bitcoinatom import BitcoinAtom @@ -249,6 +250,7 @@ class CRYPTOCURRENCIES: BeetleCoin.NAME: BeetleCoin, BelaCoin.NAME: BelaCoin, Binance.NAME: Binance, + Bismuth.NAME: Bismuth, BitCloud.NAME: BitCloud, Bitcoin.NAME: Bitcoin, BitcoinAtom.NAME: BitcoinAtom, diff --git a/hdwallet/cryptocurrencies/bismuth.py b/hdwallet/cryptocurrencies/bismuth.py new file mode 100644 index 00000000..6f4226cd --- /dev/null +++ b/hdwallet/cryptocurrencies/bismuth.py @@ -0,0 +1,248 @@ +# hdwallet/cryptocurrencies/bismuth.py + +from typing import Optional, Type, List +from hashlib import sha256 +import inspect + +from ..consts import Info +from ..eccs import SLIP10Secp256k1ECC +from ..exceptions import CryptocurrencyError +from ..libs.ecc import hash160 as _hash160 # internal HASH160 helper +from .icryptocurrency import ICryptocurrency, INetwork + +# ---------------- Address subtypes (per polysign) ---------------- + +class BISSubType: + MAINNET_REGULAR = "MAINNET_REGULAR" + MAINNET_MULTISIG = "MAINNET_MULTISIG" + TESTNET_REGULAR = "TESTNET_REGULAR" + TESTNET_MULTISIG = "TESTNET_MULTISIG" + +# Version bytes -> Base58Check prefixes ("Bis1...", "tBis...") +_VERSION_BYTES = { + BISSubType.MAINNET_REGULAR: bytes.fromhex("4f545b"), + BISSubType.MAINNET_MULTISIG: bytes.fromhex("4f54c8"), + BISSubType.TESTNET_REGULAR: bytes.fromhex("017ab685"), + BISSubType.TESTNET_MULTISIG: bytes.fromhex("0146eba5"), +} +_ALL_VERSION_BYTES = set(_VERSION_BYTES.values()) + +# --------- helpers for network version registries (get_version) --------- + +class _FixedVersions: + def __init__(self, value: int): + self._value = value + def get_version(self, *_args, **_kwargs) -> int: + return self._value + +# ---------------- Real network classes ---------------- + +class BismuthMainnet(INetwork): + NAME = "mainnet" + XPRIVATE_KEY_VERSIONS = _FixedVersions(0x0488ADE4) # xprv + XPUBLIC_KEY_VERSIONS = _FixedVersions(0x0488B21E) # xpub + WIF_VERSIONS = _FixedVersions(0x80) + # NOTE: multi-byte version prefixes + PUBLIC_KEY_ADDRESS_PREFIX = int.from_bytes(bytes.fromhex("4f545b"), "big") + SCRIPT_ADDRESS_PREFIX = int.from_bytes(bytes.fromhex("4f54c8"), "big") + +class BismuthTestnet(INetwork): + NAME = "testnet" + XPRIVATE_KEY_VERSIONS = _FixedVersions(0x04358394) # tprv + XPUBLIC_KEY_VERSIONS = _FixedVersions(0x043587CF) # tpub + WIF_VERSIONS = _FixedVersions(0xEF) + PUBLIC_KEY_ADDRESS_PREFIX = int.from_bytes(bytes.fromhex("017ab685"), "big") + SCRIPT_ADDRESS_PREFIX = int.from_bytes(bytes.fromhex("0146eba5"), "big") + +# ------------------------------ Coin ----------------------------- + +class Bismuth(ICryptocurrency): + NAME = "Bismuth" + SYMBOL = "BIS" + INFO = Info({ + "SOURCE_CODE": "https://github.com/bismuthfoundation/Bismuth", + "WHITEPAPER": "https://bismuthcoin.org/pdf/whitepaper.pdf", + "WEBSITES": [ + "https://bismuthcoin.org", + "https://bismuth.live" + ] + }) + COIN_TYPE = 209 + DEFAULT_PATH = "m/44'/209'/0'/0/{address}" + ECC = SLIP10Secp256k1ECC # secp256k1 + + class ADDRESSES: + P2PKH = "P2PKH" + @classmethod + def get_addresses(cls) -> List[str]: return [cls.P2PKH] + @classmethod + def length(cls) -> int: return 1 + @classmethod + def names(cls) -> List[str]: return cls.get_addresses() + @classmethod + def default(cls) -> str: return cls.P2PKH + @classmethod + def has(cls, name: str) -> bool: return name == cls.P2PKH + + DEFAULT_ADDRESS = ADDRESSES.P2PKH + + class MNEMONICS: + @classmethod + def get_mnemonics(cls) -> List[str]: return ["BIP39"] + + class SEEDS: + @classmethod + def get_seeds(cls) -> List[str]: return ["BIP39"] + + class HDS: + @classmethod + def get_hds(cls) -> List[str]: return ["BIP44"] + + class NETWORKS: + MAINNET: Type[INetwork] = BismuthMainnet + TESTNET: Type[INetwork] = BismuthTestnet + _MAP = { "mainnet": MAINNET, "testnet": TESTNET } + + @classmethod + def is_network(cls, network) -> bool: + if isinstance(network, str): return network.lower() in cls._MAP + return inspect.isclass(network) and issubclass(network, INetwork) + + @classmethod + def get_network(cls, network=None) -> Type[INetwork]: + if network is None: return cls.MAINNET + if isinstance(network, str): + key = network.lower() + if key in cls._MAP: return cls._MAP[key] + raise CryptocurrencyError("Unknown Bismuth network", + expected=list(cls._MAP.keys()), got=network) + if inspect.isclass(network) and issubclass(network, INetwork): + name = getattr(network, "NAME", "").lower() + if name in cls._MAP: return cls._MAP[name] + raise CryptocurrencyError("Unrecognized Bismuth INetwork subclass", + expected=[c.__name__ for c in cls._MAP.values()], + got=getattr(network, "__name__", str(network))) + raise CryptocurrencyError("Invalid Bismuth network type", + expected=["mainnet","testnet", + BismuthMainnet.__name__, BismuthTestnet.__name__], + got=repr(network)) + + @classmethod + def get_networks(cls) -> List[Type[INetwork]]: + return [cls.MAINNET, cls.TESTNET] + + @classmethod + def name_of(cls, network) -> str: + return cls.get_network(network).NAME + + # --------------------------- Base58 (local, robust) -------------------------- + + _B58_ALPHABET = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + _B58_INDEX = {c: i for i, c in enumerate(_B58_ALPHABET)} + + @classmethod + def _b58encode(cls, data: bytes) -> str: + if not data: return "" + # leading zeros + zeros = len(data) - len(data.lstrip(b"\x00")) + num = int.from_bytes(data, "big") + enc = bytearray() + while num > 0: + num, rem = divmod(num, 58) + enc.append(cls._B58_ALPHABET[rem]) + enc.extend(b"1" * zeros) + enc.reverse() + return enc.decode("ascii") + + @classmethod + def _b58decode(cls, text: str) -> bytes: + if not isinstance(text, str): raise TypeError("address must be str") + raw = text.encode("ascii") + num = 0 + zeros = 0 + for ch in raw: + if ch == ord("1"): + zeros += 1 + else: + break + for ch in raw: + if ch == ord(" "): + continue + val = cls._B58_INDEX.get(ch) + if val is None: + raise ValueError("invalid base58 character") + num = num * 58 + val + # re-encode to bytes; adjust for leading zeros + full = num.to_bytes((num.bit_length() + 7) // 8, "big") if num else b"" + return b"\x00" * zeros + full.lstrip(b"\x00") if full else b"\x00" * zeros + + # --------------------------- public API ----------------------- + + @classmethod + def address_from_public_key( + cls, + public_key_bytes: bytes, + network: Optional[Type[INetwork]] = None, + *, + subtype: Optional[str] = None, + address: Optional[str] = None, + ) -> str: + """ + Build a Bismuth ECDSA Base58Check address from a COMPRESSED secp256k1 pubkey. + """ + # Guard: compressed 33-byte secp256k1 key + if len(public_key_bytes) != 33 or public_key_bytes[0] not in (0x02, 0x03): + raise CryptocurrencyError( + "Bismuth requires a compressed secp256k1 public key (33 bytes, prefix 0x02/0x03)." + ) + + net = cls.NETWORKS.get_network(network) + kind = address or cls.DEFAULT_ADDRESS + st = subtype or cls._subtype_from_network_and_kind(net, kind) + version = cls._version_for(st) + + h160 = _hash160(public_key_bytes) + payload = version + h160 + checksum = sha256(sha256(payload).digest()).digest()[:4] + return cls._b58encode(payload + checksum) + + @classmethod + def _version_for(cls, subtype: str) -> bytes: + try: + return _VERSION_BYTES[subtype] + except KeyError as exc: + raise CryptocurrencyError( + "Unknown Bismuth address subtype", + expected=list(_VERSION_BYTES.keys()), + got=subtype, + ) from exc + + @classmethod + def _subtype_from_network_and_kind(cls, network, _kind: str) -> str: + # We expose only P2PKH -> regular subtype + is_test = (cls.NETWORKS.name_of(network) == "testnet") + return BISSubType.TESTNET_REGULAR if is_test else BISSubType.MAINNET_REGULAR + + @classmethod + def is_valid_address(cls, addr: str) -> bool: + """ + Base58Check validator for Bismuth (supports 3- or 4-byte versions). + """ + if not isinstance(addr, str) or len(addr) < 8: + return False + try: + raw = cls._b58decode(addr) + except Exception: + return False + + # Try version lengths we actually use + for ver_len in (3, 4): + if len(raw) != ver_len + 20 + 4: + continue + version, h160, chksum = raw[:ver_len], raw[ver_len:-4], raw[-4:] + if version not in _ALL_VERSION_BYTES or len(h160) != 20: + continue + calc = sha256(sha256(version + h160).digest()).digest()[:4] + if calc == chksum: + return True + return False diff --git a/tests/test_bismuth.py b/tests/test_bismuth.py new file mode 100644 index 00000000..4f3d3f27 --- /dev/null +++ b/tests/test_bismuth.py @@ -0,0 +1,116 @@ +# tests/test_bismuth.py + +import pytest + +from hdwallet import HDWallet +from hdwallet.hds import BIP44HD +from hdwallet.derivations import BIP44Derivation, CHANGES +from hdwallet.mnemonics import BIP39Mnemonic +from hdwallet.cryptocurrencies import Bismuth + +MNEMONIC = ( + "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" +) + +# Deterministic mainnet vectors (P2PKH) for i = 0..4 +EXPECTED_MAINNET_P2PKH = [ + "Bis1LenEHPex4WwY3BLxFGRmxNtsvKqgxkSbh", + "Bis1To2TU8R1Zpu8VHa5S1yindQ9cNEk3z8BG", + "Bis1WFnMuX97jB2gqF6hV8UUoKxs4zEZA1Vjo", + "Bis1LYb4s41E3TotifSRfKGZVJ3UfAhLFCYLt", + "Bis1TzdNTxA8UjjuA4aAn43eTpCobd6BjdFT3", +] + +# ----- helpers --------------------------------------------------- + +def _wallet_for_index(i: int, network): + """Build a wallet positioned at the leaf index i..i+1 for the given network.""" + return ( + HDWallet(cryptocurrency=Bismuth, hd=BIP44HD, network=network) + .from_mnemonic(BIP39Mnemonic(MNEMONIC)) + .from_derivation( + BIP44Derivation( + coin_type=Bismuth.COIN_TYPE, + account=0, + change=CHANGES.EXTERNAL_CHAIN, + address=(i, i + 1), + ) + ) + ) + +def _leaf_row(hdw) -> dict: + """Return the deepest row from dumps() (the actual leaf address).""" + rows = list(hdw.dumps(exclude={"root", "indexes"})) + assert rows, "no rows returned" + return max(rows, key=lambda r: r.get("at", {}).get("path", "").count("/")) + +def _leaf_address(hdw) -> str: + return _leaf_row(hdw)["address"] + +def _leaf_pubkey_bytes(hdw) -> bytes: + """Get compressed secp256k1 public key bytes from the current leaf.""" + row = _leaf_row(hdw) + pk = row.get("public_key") or hdw.public_key() + if isinstance(pk, (bytes, bytearray)): + return bytes(pk) + return bytes.fromhex(pk) + +# ----- vectors & structure tests -------------------------------- + +def test_mainnet_vectors_first5(): + addrs = [] + for i in range(5): + hdw = _wallet_for_index(i, Bismuth.NETWORKS.MAINNET) + addrs.append(_leaf_address(hdw)) + assert addrs == EXPECTED_MAINNET_P2PKH + +def test_testnet_prefixes_first3(): + addrs = [] + for i in range(3): + hdw = _wallet_for_index(i, Bismuth.NETWORKS.TESTNET) + addrs.append(_leaf_address(hdw)) + assert all(a.startswith("tBis") for a in addrs) + +def test_network_metadata_present(): + for net in (Bismuth.NETWORKS.MAINNET, Bismuth.NETWORKS.TESTNET): + for attr in ( + "XPRIVATE_KEY_VERSIONS", + "XPUBLIC_KEY_VERSIONS", + "WIF_VERSIONS", + "PUBLIC_KEY_ADDRESS_PREFIX", + "SCRIPT_ADDRESS_PREFIX", + ): + assert hasattr(net, attr) + +# ----- validator & pubkey behavior ------------------------------- + +def test_validator_accepts_expected_mainnet_addresses(): + for addr in EXPECTED_MAINNET_P2PKH: + assert Bismuth.is_valid_address(addr) + +def test_validator_rejects_bad_checksum(): + hdw = _wallet_for_index(0, Bismuth.NETWORKS.MAINNET) + addr = _leaf_address(hdw) + # mutate last character to break Base58Check while staying in charset + bad = addr[:-1] + ("1" if addr[-1] != "1" else "2") + assert not Bismuth.is_valid_address(bad) + +def test_pubkey_is_compressed_33_bytes(): + hdw = _wallet_for_index(0, Bismuth.NETWORKS.MAINNET) + pub = _leaf_pubkey_bytes(hdw) + assert isinstance(pub, (bytes, bytearray)) + assert len(pub) == 33 and pub[0] in (0x02, 0x03) + +def test_build_testnet_address_from_pubkey_bytes(): + # Derive once on mainnet, then recompute address on TESTNET using the same pubkey + hdw = _wallet_for_index(0, Bismuth.NETWORKS.MAINNET) + pub = _leaf_pubkey_bytes(hdw) + + taddr = Bismuth.address_from_public_key( + public_key_bytes=pub, + network=Bismuth.NETWORKS.TESTNET, + ) + assert isinstance(taddr, str) + assert taddr.startswith("tBis") + assert Bismuth.is_valid_address(taddr)