git / moonbite-network / wallet.py · raw

"""MyCoin — Milestone 8: Wallet & Privacy (Bitcoin whitepaper section 10).This module gives users a way to hold keys, derive human-shareable addresses,check balances against a UTXO set, and build spendable transactions.Privacy (whitepaper section 10): "a new key pair should be used for eachtransaction to keep them from being linked to a common owner." `Wallet`follows this: every send draws change to a freshly generated key, and callersare encouraged to call `new_key()` per incoming payment.Trade-offs vs. real Bitcoin (the "~10% difference"):  * Addresses are MoonBite bech32 ("moon1…") over a *single* SHA-256 public-key    hash. Real Bitcoin uses RIPEMD-160(SHA-256(pubkey)) (a 20-byte HASH160). We    avoid RIPEMD-160 because it is missing from some OpenSSL 3 builds; the bech32    envelope carries the "moon" HRP plus a checksum, the same idea as a Bitcoin    native-segwit address. (Base58Check helpers are retained for internal use.)  * Keys here are generated at random on each `new_key()` call and kept in    memory. Real wallets use hierarchical-deterministic derivation (BIP32/39)    so all keys stem from one recoverable seed. Here there is no seed and no    persistence — losing the process loses the keys.  * Coin selection is a simple greedy "largest/first-fit" scan, not the more    sophisticated selection real wallets use to minimize fees and change."""from __future__ import annotationsimport hashlibimport hmacimport unicodedatafrom typing import Iterablefrom transaction import (    Transaction,    TxInput,    TxOutput,    generate_keypair,    pubkey_hash,    sha256d,)from ecdsa import SigningKey, SECP256k1from mnemonic import Mnemonic# --------------------------------------------------------------------------- ## Base58Check address encoding# --------------------------------------------------------------------------- #_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"_BASE = len(_ALPHABET)_INDEX = {c: i for i, c in enumerate(_ALPHABET)}def base58_encode(data: bytes) -> str:    """Encode bytes to a Base58 string (Bitcoin alphabet).    Leading zero bytes are preserved as leading '1' characters, matching    Bitcoin's convention so that the version byte round-trips exactly.    """    # Count and strip leading zero bytes; they map to leading '1's.    n_leading_zeros = 0    for b in data:        if b == 0:            n_leading_zeros += 1        else:            break    num = int.from_bytes(data, "big")    chars: list[str] = []    while num > 0:        num, rem = divmod(num, _BASE)        chars.append(_ALPHABET[rem])    chars.append(_ALPHABET[0] * n_leading_zeros)    return "".join(reversed(chars))def base58_decode(s: str) -> bytes:    """Decode a Base58 string back to bytes. Inverse of `base58_encode`."""    # Leading '1's decode back to leading zero bytes.    n_leading_ones = 0    for c in s:        if c == _ALPHABET[0]:            n_leading_ones += 1        else:            break    num = 0    for c in s:        if c not in _INDEX:            raise ValueError(f"invalid base58 character: {c!r}")        num = num * _BASE + _INDEX[c]    # Convert the big integer back to bytes (minimal length), then re-prepend    # the leading zero bytes that the '1' prefix represented.    body = num.to_bytes((num.bit_length() + 7) // 8, "big") if num > 0 else b""    return b"\x00" * n_leading_ones + body# --------------------------------------------------------------------------- ## Bech32 address encoding (BIP173) — MoonBite "moon1…" addresses## MoonBite's canonical human-readable address is bech32 with the "moon" HRP# (mainnet). Because this educational chain hashes public keys with 32-byte# SHA-256 rather than Bitcoin's 20-byte HASH160, a Base58Check version byte can# only ever yield a leading *digit*; bech32 gives a clean, branded "moon1…"# prefix instead, and round-trips the existing 32-byte hash with no consensus# change. Base58 helpers above are retained for internal use and tests.# --------------------------------------------------------------------------- #_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"_MOONBITE_HRP = "moon"def _bech32_polymod(values: list[int]) -> int:    generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]    chk = 1    for value in values:        top = chk >> 25        chk = (chk & 0x1FFFFFF) << 5 ^ value        for i in range(5):            chk ^= generator[i] if ((top >> i) & 1) else 0    return chkdef _bech32_hrp_expand(hrp: str) -> list[int]:    return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]def _bech32_verify_checksum(hrp: str, data: list[int]) -> bool:    return _bech32_polymod(_bech32_hrp_expand(hrp) + data) == 1def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:    values = _bech32_hrp_expand(hrp) + data    polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1    return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]def _bech32_encode(hrp: str, data: list[int]) -> str:    combined = data + _bech32_create_checksum(hrp, data)    return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)def _bech32_decode(bech: str):    if any(ord(x) < 33 or ord(x) > 126 for x in bech) or (        bech.lower() != bech and bech.upper() != bech    ):        return (None, None)    bech = bech.lower()    pos = bech.rfind("1")    if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:        return (None, None)    if not all(x in _BECH32_CHARSET for x in bech[pos + 1:]):        return (None, None)    hrp = bech[:pos]    data = [_BECH32_CHARSET.find(x) for x in bech[pos + 1:]]    if not _bech32_verify_checksum(hrp, data):        return (None, None)    return (hrp, data[:-6])def _convertbits(data, frombits: int, tobits: int, pad: bool = True):    """Regroup a bit-stream between 8-bit bytes and 5-bit bech32 symbols."""    acc = 0    bits = 0    ret: list[int] = []    maxv = (1 << tobits) - 1    max_acc = (1 << (frombits + tobits - 1)) - 1    for value in data:        if value < 0 or (value >> frombits):            return None        acc = ((acc << frombits) | value) & max_acc        bits += frombits        while bits >= tobits:            bits -= tobits            ret.append((acc >> bits) & maxv)    if pad:        if bits:            ret.append((acc << (tobits - bits)) & maxv)    elif bits >= frombits or ((acc << (tobits - bits)) & maxv):        return None    return retdef address_from_pubkey_hash(pkh_hex: str, hrp: str = _MOONBITE_HRP) -> str:    """Build a MoonBite bech32 address ("moon1…") from a public-key hash (hex).    The pubkey-hash bytes are regrouped from 8-bit to 5-bit symbols and encoded    under the given human-readable prefix (default "moon" for mainnet) with a    bech32 checksum.    """    data = _convertbits(bytes.fromhex(pkh_hex), 8, 5, True)    if data is None:        raise ValueError("cannot encode pubkey hash")    return _bech32_encode(hrp, data)def pubkey_hash_from_address(addr: str) -> str:    """Decode a bech32 address back to its public-key hash (hex).    Raises ValueError if the address is malformed or the bech32 checksum fails.    """    hrp, data = _bech32_decode(addr)    if hrp is None or data is None:        raise ValueError("invalid address")    decoded = _convertbits(data, 5, 8, False)    if decoded is None:        raise ValueError("invalid address payload")    return bytes(decoded).hex()def is_valid_address(addr: str) -> bool:    """True if `addr` is a well-formed Base58Check address with a valid checksum."""    try:        pubkey_hash_from_address(addr)        return True    except Exception:        return False# --------------------------------------------------------------------------- ## Wallet# --------------------------------------------------------------------------- #class Wallet:    """An in-memory keychain implementing "a new key pair per transaction".    Keys are indexed by their pubkey_hash so that, given a UTXO locked to a    hash, the wallet can find the signing key that authorizes spending it.    """    def __init__(self) -> None:        # pubkey_hash (hex) -> SigningKey        self._keys: dict[str, SigningKey] = {}        # pubkey_hash (hex) -> address (base58), preserving insertion order        self._addresses: dict[str, str] = {}        # Minimal record of sends this wallet created.        self.history: list[dict] = []    # ----- key management ------------------------------------------------- #    def new_key(self) -> str:        """Generate a fresh keypair, store it, and return its Base58 address."""        sk, pubkey_hex = generate_keypair()        pkh = pubkey_hash(pubkey_hex)        addr = address_from_pubkey_hash(pkh)        self._keys[pkh] = sk        self._addresses[pkh] = addr        return addr    @property    def addresses(self) -> list[str]:        """All addresses this wallet owns, in the order they were created."""        return list(self._addresses.values())    def owns(self, pubkey_hash_hex: str) -> bool:        """True if this wallet holds the signing key for `pubkey_hash_hex`."""        return pubkey_hash_hex in self._keys    # ----- persistence ---------------------------------------------------- #    def export_privkeys(self) -> list[str]:        """Serialize every signing key as hex, for saving to disk."""        return [sk.to_string().hex() for sk in self._keys.values()]    def load_privkey(self, privkey_hex: str) -> str:        """Restore a signing key from hex, re-deriving its address (returned).        The pubkey_hash and Base58 address are deterministic functions of the        key, so a reloaded key reproduces exactly the same address it had        before, letting a wallet recover ownership of its coins after a restart.        """        sk = SigningKey.from_string(bytes.fromhex(privkey_hex), curve=SECP256k1)        pubkey_hex = sk.get_verifying_key().to_string().hex()        pkh = pubkey_hash(pubkey_hex)        addr = address_from_pubkey_hash(pkh)        self._keys[pkh] = sk        self._addresses[pkh] = addr        return addr    # ----- balance -------------------------------------------------------- #    def balance(self, utxos: Iterable[tuple[str, int, TxOutput]]) -> int:        """Sum the amounts of UTXOs locked to a pubkey_hash this wallet owns."""        return sum(            out.amount for _txid, _idx, out in utxos if self.owns(out.pubkey_hash)        )    # ----- spending ------------------------------------------------------- #    def create_transaction(        self,        utxos: Iterable[tuple[str, int, TxOutput]],        to_address: str,        amount: int,        fee: int = 0,        change_address: str | None = None,    ) -> Transaction:        """Build and sign a transaction paying `amount` to `to_address`.        Only owned UTXOs are considered. Owned UTXOs are greedily accumulated        until they cover `amount + fee`; otherwise ValueError("insufficient        funds") is raised. Output 0 pays the recipient; any surplus over        `amount + fee` is returned as change to `change_address` (default: a        freshly generated address, honoring the "new key per transaction"        privacy guidance). A zero-value change output is never created.        """        if amount <= 0:            raise ValueError("amount must be positive")        if fee < 0:            raise ValueError("fee must be non-negative")        target = amount + fee        # Greedily select owned UTXOs until we cover the target.        selected: list[tuple[str, int, TxOutput]] = []        total = 0        for txid, index, out in utxos:            if not self.owns(out.pubkey_hash):                continue            selected.append((txid, index, out))            total += out.amount            if total >= target:                break        if total < target:            raise ValueError("insufficient funds")        # Build outputs: recipient first, then change (if any).        outputs = [TxOutput(amount, pubkey_hash_from_address(to_address))]        change = total - target        if change > 0:            if change_address is None:                change_address = self.new_key()  # new key per transaction            outputs.append(                TxOutput(change, pubkey_hash_from_address(change_address))            )        # Build inputs referencing each selected UTXO.        inputs = [            TxInput(prev_txid=txid, output_index=index)            for txid, index, _out in selected        ]        tx = Transaction(inputs=inputs, outputs=outputs)        # Sign each input with the key for the UTXO it spends.        for i, (_txid, _index, out) in enumerate(selected):            tx.sign_input(i, self._keys[out.pubkey_hash])        self.history.append({"txid": tx.txid, "amount": amount, "to": to_address})        return tx# --------------------------------------------------------------------------- ## HD Wallet (BIP32/39) — Hierarchical Deterministic key derivation# --------------------------------------------------------------------------- #class HDWallet(Wallet):    """HD Wallet supporting BIP39 mnemonic seed phrases and BIP32 key derivation.    Extends Wallet with:    - BIP39 mnemonic seed phrase generation (12/24 words)    - BIP32 hierarchical key derivation (m/44'/0'/0'/0/n path)    - Seed persistence and recovery    - Unlimited address generation from one seed    """    def __init__(self, mnemonic_phrase: str | None = None, passphrase: str = "") -> None:        """Initialize HD wallet from mnemonic or generate new seed.        Args:            mnemonic_phrase: BIP39 mnemonic (12 or 24 words). If None, generates new.            passphrase: Optional BIP39 passphrase for seed derivation.        """        super().__init__()        self.mnemonic_obj = Mnemonic("english")        if mnemonic_phrase is None:            # Generate new 12-word mnemonic            mnemonic_phrase = self.mnemonic_obj.generate(strength=128)        # Validate mnemonic        if not self.mnemonic_obj.check(mnemonic_phrase):            raise ValueError("invalid BIP39 mnemonic phrase")        self.mnemonic = mnemonic_phrase        self.passphrase = passphrase        # Derive BIP32 root from mnemonic        seed = self.mnemonic_obj.to_seed(mnemonic_phrase, passphrase)        self._root_key = self._derive_bip32_root(seed)        # Track derivation path counter for new addresses        self._derivation_index = 0    @staticmethod    def _derive_bip32_root(seed: bytes) -> dict:        """Derive BIP32 root key from seed (Bitcoin standard).        Returns dict with 'private_key' (bytes), 'chain_code' (bytes).        """        hmac_result = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()        private_key = hmac_result[:32]        chain_code = hmac_result[32:]        return {"private_key": private_key, "chain_code": chain_code}    @staticmethod    def _ckd_priv(key_data: dict, index: int) -> dict:        """Child Key Derivation (private) — BIP32 standard.        Args:            key_data: Parent key dict with 'private_key', 'chain_code'            index: Child index (0-2^31-1 for normal, 2^31+ for hardened)        Returns: Child key dict with same structure        """        if index >= 0x80000000:            # Hardened derivation: use private key            data = b"\x00" + key_data["private_key"] + index.to_bytes(4, "big")        else:            # Normal derivation: use public key (derived from private)            from ecdsa.util import sigencode_string            vk = SigningKey.from_string(                key_data["private_key"], curve=SECP256k1            ).get_verifying_key()            data = vk.to_string() + index.to_bytes(4, "big")        hmac_result = hmac.new(            key_data["chain_code"], data, hashlib.sha512        ).digest()        child_key = (            int.from_bytes(hmac_result[:32], "big")            + int.from_bytes(key_data["private_key"], "big")        ) % (SECP256k1.order or 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff)        child_key_bytes = child_key.to_bytes(32, "big")        child_chain_code = hmac_result[32:]        return {"private_key": child_key_bytes, "chain_code": child_chain_code}    def _derive_path(self, path: str = "m/44'/0'/0'/0/0") -> dict:        """Derive key at BIP32 path (e.g., "m/44'/0'/0'/0/0" for first address).        Standard MoonBite path: m/44'/0'/0'/0/n (account 0, change 0, index n)        """        key = self._root_key        for component in path.split("/")[1:]:  # Skip 'm'            if component == "":                continue            hardened = component.endswith("'")            index = int(component.rstrip("'"))            if hardened:                index += 0x80000000            key = self._ckd_priv(key, index)        return key    def new_key(self) -> str:        """Generate next HD address using derivation path m/44'/0'/0'/0/n."""        # Standard derivation: m/44'/0'/0'/0/n        path = f"m/44'/0'/0'/0/{self._derivation_index}"        self._derivation_index += 1        key_data = self._derive_path(path)        sk = SigningKey.from_string(key_data["private_key"], curve=SECP256k1)        pubkey_hex = sk.get_verifying_key().to_string().hex()        pkh = pubkey_hash(pubkey_hex)        addr = address_from_pubkey_hash(pkh)        self._keys[pkh] = sk        self._addresses[pkh] = addr        return addr    def export_seed(self) -> str:        """Export mnemonic seed phrase for recovery."""        return self.mnemonic    def derive_address(self, index: int) -> str:        """Derive a specific address by index (m/44'/0'/0'/0/index).        Useful for recovering specific addresses from seed without creating new ones.        """        path = f"m/44'/0'/0'/0/{index}"        key_data = self._derive_path(path)        sk = SigningKey.from_string(key_data["private_key"], curve=SECP256k1)        pubkey_hex = sk.get_verifying_key().to_string().hex()        pkh = pubkey_hash(pubkey_hex)        addr = address_from_pubkey_hash(pkh)        # Store the key if not already present        if pkh not in self._keys:            self._keys[pkh] = sk            self._addresses[pkh] = addr        return addr    @classmethod    def from_mnemonic(cls, mnemonic: str, passphrase: str = "") -> HDWallet:        """Recover wallet from BIP39 mnemonic seed phrase."""        return cls(mnemonic, passphrase)# --------------------------------------------------------------------------- ## Seed-phrase key derivation — reference mirror of the browser / MoonBite Core# --------------------------------------------------------------------------- ## The self-custody wallet derives its address in the browser so the user's seed# phrase never crosses the network (the old server-side seed endpoints are# retired and answer 410). This keeps an INDEPENDENT Python reference of that# exact derivation so tests/test_address_derivation.py can run the browser code# (static/moonbite-address.js + moonbite-hd.js + moonbite-bip39.js) under node# and assert the two agree byte-for-byte; if either side drifts the test fails# instead of silently stranding a user's coins at an address their other device# cannot see.## This mirrors the REAL MoonBite Core (native-segwit / P2WPKH) scheme the live# wallet and the node use — NOT the single-SHA256 toy-chain scheme used by the# Wallet/HDWallet classes and transaction.pubkey_hash above:#   * a valid BIP39 mnemonic  -> BIP32/BIP84 key at m/84'/2'/0'/0/0#   * any other phrase        -> legacy scalar sha256("moonbite-seed-v1:"+phrase)#   * address = bech32("moon", witver 0, RIPEMD160(SHA256(compressed pubkey)))# It is a pure reference/oracle: it must never be wired to an HTTP endpoint — a# self-custody seed stays in the user's browser.SEED_DERIVATION_PREFIX = "moonbite-seed-v1:"_BIP84_PATH = "m/84'/2'/0'/0/0"_CURVE_N = SECP256k1.orderdef normalize_seed_phrase(phrase: str) -> str:    """Canonicalize a user-typed phrase so trivial typing differences agree.    Matches the browser's normalize() exactly: Unicode NFKD, trimmed, internal    whitespace collapsed to single spaces, lower-cased. Case and whitespace runs    carry no meaning in a written phrase and a user retyping their seed on a new    device will not reproduce them; everything else is preserved verbatim. This    normalizes formatting, not content.    """    return " ".join(        unicodedata.normalize("NFKD", phrase or "").strip().lower().split()    )def _ripemd160(data: bytes) -> bytes:    """RIPEMD-160, preferring OpenSSL with a pure-Python fallback.    Some OpenSSL 3 builds ship without RIPEMD-160, so the fallback keeps address    derivation (and CI) working wherever this reference runs.    """    try:        h = hashlib.new("ripemd160")        h.update(data)        return h.digest()    except (ValueError, TypeError):  # pragma: no cover - environment-dependent        return _ripemd160_pure(data)def _hash160(data: bytes) -> bytes:    """HASH160 = RIPEMD160(SHA256(data)), the P2WPKH pubkey-hash."""    return _ripemd160(hashlib.sha256(data).digest())def _compressed_pubkey(scalar: int) -> bytes:    """33-byte SEC1 compressed public key for a secp256k1 private scalar."""    vk = SigningKey.from_secret_exponent(scalar, curve=SECP256k1).get_verifying_key()    xy = vk.to_string()  # raw X || Y, 32 bytes each    x, y = xy[:32], xy[32:]    return (b"\x03" if (y[-1] & 1) else b"\x02") + xdef _bip32_master(seed: bytes) -> tuple[bytes, bytes]:    digest = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()    return digest[:32], digest[32:]def _bip32_ckd(k: bytes, c: bytes, index: int) -> tuple[bytes, bytes]:    """One BIP32 child-key derivation step (private parent -> private child)."""    if index >= 0x80000000:  # hardened        data = b"\x00" + k + index.to_bytes(4, "big")    else:        data = _compressed_pubkey(int.from_bytes(k, "big")) + index.to_bytes(4, "big")    digest = hmac.new(c, data, hashlib.sha512).digest()    child = (int.from_bytes(digest[:32], "big") + int.from_bytes(k, "big")) % _CURVE_N    if child == 0:  # pragma: no cover - astronomically unlikely        raise ValueError("invalid derived key")    return child.to_bytes(32, "big"), digest[32:]def _bip32_derive(seed: bytes, path: str) -> bytes:    """Private key (32 bytes) at a BIP32 path like "m/84'/2'/0'/0/0"."""    k, c = _bip32_master(seed)    for seg in path.split("/")[1:]:        hardened = seg.endswith("'") or seg.endswith("h")        idx = int(seg[:-1] if hardened else seg) + (0x80000000 if hardened else 0)        k, c = _bip32_ckd(k, c, idx)    return kdef _bip39_seed(normalized_mnemonic: str, passphrase: str = "") -> bytes:    """BIP39 seed (64 bytes): PBKDF2-HMAC-SHA512(mnemonic, "mnemonic"+passphrase, 2048).    The passphrase (the optional "extra password") is only NFKD-normalized, per    BIP39: case, spaces and symbols are significant. An empty passphrase is the    plain BIP39 seed every existing wallet uses.    """    salt = unicodedata.normalize("NFKD", "mnemonic" + (passphrase or "")).encode("utf-8")    return hashlib.pbkdf2_hmac(        "sha512", normalized_mnemonic.encode("utf-8"), salt, 2048, dklen=64    )_BIP39 = Mnemonic("english")def _is_valid_bip39(normalized_mnemonic: str) -> bool:    """True only for a well-formed BIP39 phrase whose checksum verifies."""    try:        return _BIP39.check(normalized_mnemonic)    except Exception:  # noqa: BLE001 - any parse error means "not BIP39"        return Falsedef privkey_from_seed_phrase(phrase: str, passphrase: str = "") -> str:    """Private scalar (hex) for a seed phrase, matching the browser wallet.    A valid BIP39 mnemonic is run through BIP32/BIP84 (m/84'/2'/0'/0/0), with    the optional BIP39 passphrase; any other phrase falls back to a legacy    scalar so an older imported phrase still resolves to a real key (a    passphrase is refused there, as in the browser). The legacy hash is reduced    mod the curve order because a raw 256-bit digest can land outside [1, n-1].    """    normalized = normalize_seed_phrase(phrase)    if not normalized:        raise ValueError("seed phrase is empty")    if _is_valid_bip39(normalized):        return _bip32_derive(_bip39_seed(normalized, passphrase), _BIP84_PATH).hex()    if passphrase:        raise ValueError("an extra password only works with a standard 12-word recovery phrase")    digest = hashlib.sha256((SEED_DERIVATION_PREFIX + normalized).encode("utf-8")).digest()    scalar = int.from_bytes(digest, "big") % _CURVE_N    if scalar == 0:  # pragma: no cover - needs a preimage of the group order        raise ValueError("degenerate key for this seed phrase")    return format(scalar, "064x")def _segwit_address_from_pubkey_hash(pkh: bytes, hrp: str = _MOONBITE_HRP) -> str:    """bech32 P2WPKH address (witness v0) for a 20-byte pubkey hash."""    prog = _convertbits(pkh, 8, 5, True)    if prog is None:  # pragma: no cover - 20 bytes always converts cleanly        raise ValueError("cannot encode pubkey hash")    return _bech32_encode(hrp, [0] + prog)def is_valid_seed_address(addr: str, hrp: str = _MOONBITE_HRP) -> bool:    """True when addr is a well-formed MoonBite P2WPKH (witness-v0) address.    Validates the real-chain segwit format the browser and Core produce, which    the single-SHA256 toy-chain ``is_valid_address`` above does not accept.    """    dhrp, data = _bech32_decode(addr)    if dhrp != hrp or not data or data[0] != 0:        return False    prog = _convertbits(data[1:], 5, 8, False)    return prog is not None and len(prog) == 20def derive_from_seed_phrase(phrase: str, passphrase: str = "") -> dict:    """Full derivation: seed phrase -> private key, public key, hash, address.    Byte-for-byte identical to static/moonbite-address.js (P2WPKH over the    COMPRESSED public key); guarded by tests/test_address_derivation.py.    """    privkey_hex = privkey_from_seed_phrase(phrase, passphrase)    compressed = _compressed_pubkey(int(privkey_hex, 16))    pkh = _hash160(compressed)    return {        "private_key": privkey_hex,        "public_key": compressed.hex(),        "pubkey_hash": pkh.hex(),        "address": _segwit_address_from_pubkey_hash(pkh),    }def _ripemd160_pure(message: bytes) -> bytes:    """Pure-Python RIPEMD-160 (public-domain reference algorithm).    Fallback for environments whose OpenSSL lacks ripemd160; verified against    hashlib's implementation across standard test vectors.    """    def rol(x: int, n: int) -> int:        x &= 0xFFFFFFFF        return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF    def f(j: int, x: int, y: int, z: int) -> int:        if j < 16:            return x ^ y ^ z        if j < 32:            return (x & y) | (~x & 0xFFFFFFFF & z)        if j < 48:            return (x | (~y & 0xFFFFFFFF)) ^ z        if j < 64:            return (x & z) | (y & (~z & 0xFFFFFFFF))        return x ^ (y | (~z & 0xFFFFFFFF))    K = [0, 0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xA953FD4E]    KK = [0x50A28BE6, 0x5C4DD124, 0x6D703EF3, 0x7A6D76E9, 0]    r = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,         7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,         3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,         1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,         4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13]    rr = [5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,          6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,          15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,          8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,          12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11]    s = [11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,         7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,         11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,         11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,         9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6]    ss = [8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,          9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,          9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,          15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,          8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11]    msglen = len(message)    padded = message + b"\x80"    padded += b"\x00" * ((56 - len(padded) % 64) % 64)    padded += (msglen * 8 & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "little")    h0, h1, h2, h3, h4 = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0    for off in range(0, len(padded), 64):        X = [int.from_bytes(padded[off + 4 * i: off + 4 * i + 4], "little") for i in range(16)]        a1, b1, c1, d1, e1 = h0, h1, h2, h3, h4        a2, b2, c2, d2, e2 = h0, h1, h2, h3, h4        for j in range(80):            t = (a1 + f(j, b1, c1, d1) + X[r[j]] + K[j // 16]) & 0xFFFFFFFF            t = (rol(t, s[j]) + e1) & 0xFFFFFFFF            a1, e1, d1, c1, b1 = e1, d1, rol(c1, 10), b1, t            t = (a2 + f(79 - j, b2, c2, d2) + X[rr[j]] + KK[j // 16]) & 0xFFFFFFFF            t = (rol(t, ss[j]) + e2) & 0xFFFFFFFF            a2, e2, d2, c2, b2 = e2, d2, rol(c2, 10), b2, t        t = (h1 + c1 + d2) & 0xFFFFFFFF        h1 = (h2 + d1 + e2) & 0xFFFFFFFF        h2 = (h3 + e1 + a2) & 0xFFFFFFFF        h3 = (h4 + a1 + b2) & 0xFFFFFFFF        h4 = (h0 + b1 + c2) & 0xFFFFFFFF        h0 = t    return b"".join(x.to_bytes(4, "little") for x in (h0, h1, h2, h3, h4))