From 649dc186b82303ce41c79903e7524a83898350de Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sat, 19 Jul 2025 08:13:57 -0600 Subject: [PATCH 01/38] Work toward raw hex seeds for SLIP-39 derivation support --- hdwallet/entropies/slip39.py | 55 +++++++++++++++++++++++++ hdwallet/mnemonics/__init__.py | 4 +- hdwallet/seeds/slip39.py | 74 ++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 hdwallet/entropies/slip39.py create mode 100644 hdwallet/seeds/slip39.py diff --git a/hdwallet/entropies/slip39.py b/hdwallet/entropies/slip39.py new file mode 100644 index 00000000..c862c025 --- /dev/null +++ b/hdwallet/entropies/slip39.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2024, Meheret Tesfaye Batu +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +from .ientropy import IEntropy + + +class SLIP39_ENTROPY_STRENGTHS: + """ + Constants representing the entropy strengths for SLIP39. + """ + ONE_HUNDRED_TWENTY_EIGHT: int = 128 + TWO_HUNDRED_FIFTY_SIX: int = 256 + FIVE_HUNDRED_TWELVE: int = 256 + + +class SLIP39Entropy(IEntropy): + """Stores entropy for SLIP-39. This data is used directly to create deterministic keys for + various cryptocurrencies. + + .. note:: + This class inherits from the ``IEntropy`` class, thereby ensuring that all functions are accessible. + + Here are available ``SLIP39_ENTROPY_STRENGTHS``: + + +--------------------------+-------+ + | Name | Value | + +==========================+=======+ + | ONE_HUNDRED_TWENTY_EIGHT | 128 | + +--------------------------+-------+ + | TWO_HUNDRED_FIFTY_SIX | 256 | + +--------------------------+-------+ + | FIVE_HUNDRED_TWELVE | 512 | + +--------------------------+-------+ + + """ + + strengths = [ + SLIP39_ENTROPY_STRENGTHS.ONE_HUNDRED_TWENTY_EIGHT, + SLIP39_ENTROPY_STRENGTHS.TWO_HUNDRED_FIFTY_SIX, + SLIP39_ENTROPY_STRENGTHS.FIVE_HUNDRED_TWELVE + ] + + @classmethod + def name(cls) -> str: + """ + Get the name of the entropy class. + + :return: The name of the entropy class. + :rtype: str + """ + + return "SLIP39" diff --git a/hdwallet/mnemonics/__init__.py b/hdwallet/mnemonics/__init__.py index 9a9d2499..4cc21023 100644 --- a/hdwallet/mnemonics/__init__.py +++ b/hdwallet/mnemonics/__init__.py @@ -54,7 +54,8 @@ class MNEMONICS: BIP39Mnemonic.name(): BIP39Mnemonic, ElectrumV1Mnemonic.name(): ElectrumV1Mnemonic, ElectrumV2Mnemonic.name(): ElectrumV2Mnemonic, - MoneroMnemonic.name(): MoneroMnemonic + MoneroMnemonic.name(): MoneroMnemonic, + SLIP39Mnemonic.name(): SLIP39Mnemonic } @classmethod @@ -120,6 +121,7 @@ def is_mnemonic(cls, name) -> bool: "ELECTRUM_V1_MNEMONIC_WORDS", "ELECTRUM_V1_MNEMONIC_LANGUAGES", "ELECTRUM_V2_MNEMONIC_WORDS", "ELECTRUM_V2_MNEMONIC_LANGUAGES", "ELECTRUM_V2_MNEMONIC_TYPES", "MONERO_MNEMONIC_WORDS", "MONERO_MNEMONIC_LANGUAGES", + "SLIP39_MNEMONIC_WORDS", "SLIP39_MNEMONIC_LANGUAGES", "MNEMONICS" ] + [ cls.__name__ for cls in MNEMONICS.classes() diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py new file mode 100644 index 00000000..71b970bc --- /dev/null +++ b/hdwallet/seeds/slip39.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2024, Meheret Tesfaye Batu +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +from typing import ( + Optional, Union +) + +import unicodedata + +from ..crypto import pbkdf2_hmac_sha512 +from ..exceptions import MnemonicError +from ..utils import bytes_to_string +from ..mnemonics import ( + IMnemonic, BIP39Mnemonic +) +from .iseed import ISeed + + +class SLP39Seed(ISeed): + """This class transmits the seed collected from SLIP-39 recovery. This entropy is used directly + to produce hierarchical deterministic wallets, unlike for BIP39, where the original entropy is + hashed and extended to 512 bits before being used. The 3 valid seed sizes are 128, 256 and 512 + bits. + + Once recovered from SLIP-39 encoding, the seed data is provided and presented as simple hex. + + .. note:: + This class inherits from the ``ISeed`` class, thereby ensuring that all functions are accessible. + + """ + @classmethod + def name(cls) -> str: + """ + Get the name of the seeds class. + + :return: The name of the seeds class. + :rtype: str + """ + + return "SLIP39" + + @classmethod + def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None) -> str: + """ + Converts a mnemonic phrase to its corresponding seed. + + The Mnemonic representation for SLIP-39 seeds is simple hex. SLIP-39 seeds may be encrypted by + their own passphrase; this passphrase is the BIP-39 seed passphrase. + + :param mnemonic: The mnemonic phrase to be decoded. Can be a string or an instance of `IMnemonic`. + :type mnemonic: Union[str, IMnemonic] + + :param passphrase: An optional passphrase used for additional security when decoding the mnemonic phrase. + :type passphrase: Optional[str] + + :return: The decoded seed as a string. + :rtype: str + """ + + mnemonic = ( + mnemonic.mnemonic() if isinstance(mnemonic, IMnemonic) else mnemonic + ) + if not BIP39Mnemonic.is_valid(mnemonic=mnemonic): + raise MnemonicError(f"Invalid {cls.name()} mnemonic words") + + salt: str = unicodedata.normalize("NFKD", ( + (cls.seed_salt_modifier + passphrase) if passphrase else cls.seed_salt_modifier + )) + return bytes_to_string(pbkdf2_hmac_sha512( + password=mnemonic, salt=salt, iteration_num=cls.seed_pbkdf2_rounds + )) From 80f92ddb5ca3c1a4a9cf15ecd9739f283975448c Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sat, 19 Jul 2025 13:17:33 -0600 Subject: [PATCH 02/38] Begin adding SLIP39 entropy and seed support --- hdwallet/mnemonics/__init__.py | 3 +-- hdwallet/seeds/__init__.py | 4 ++++ hdwallet/seeds/bip39.py | 6 +++++ hdwallet/seeds/iseed.py | 2 +- hdwallet/seeds/slip39.py | 40 +++++++++++++++------------------- hdwallet/utils.py | 26 ++++++++++++---------- 6 files changed, 43 insertions(+), 38 deletions(-) diff --git a/hdwallet/mnemonics/__init__.py b/hdwallet/mnemonics/__init__.py index 4cc21023..50186dfa 100644 --- a/hdwallet/mnemonics/__init__.py +++ b/hdwallet/mnemonics/__init__.py @@ -54,8 +54,7 @@ class MNEMONICS: BIP39Mnemonic.name(): BIP39Mnemonic, ElectrumV1Mnemonic.name(): ElectrumV1Mnemonic, ElectrumV2Mnemonic.name(): ElectrumV2Mnemonic, - MoneroMnemonic.name(): MoneroMnemonic, - SLIP39Mnemonic.name(): SLIP39Mnemonic + MoneroMnemonic.name(): MoneroMnemonic } @classmethod diff --git a/hdwallet/seeds/__init__.py b/hdwallet/seeds/__init__.py index 113fc041..fbc0bd76 100644 --- a/hdwallet/seeds/__init__.py +++ b/hdwallet/seeds/__init__.py @@ -12,6 +12,7 @@ from ..exceptions import SeedError from .algorand import AlgorandSeed from .bip39 import BIP39Seed +from .slip39 import SLIP39Seed from .cardano import CardanoSeed from .electrum import ( ElectrumV1Seed, ElectrumV2Seed @@ -35,6 +36,8 @@ class SEEDS: +--------------+------------------------------------------------------+ | BIP39 | :class:`hdwallet.seeds.bip39.BIP39Seed` | +--------------+------------------------------------------------------+ + | SLIP39 | :class:`hdwallet.seeds.sli39.SLIP39Seed` | + +--------------+------------------------------------------------------+ | Cardano | :class:`hdwallet.seeds.cardano.CardanoSeed` | +--------------+------------------------------------------------------+ | Electrum-V1 | :class:`hdwallet.seeds.electrum.v1.ElectrumV1Seed` | @@ -48,6 +51,7 @@ class SEEDS: dictionary: Dict[str, Type[ISeed]] = { AlgorandSeed.name(): AlgorandSeed, BIP39Seed.name(): BIP39Seed, + SLIP39Seed.name(): SLIP39Seed, CardanoSeed.name(): CardanoSeed, ElectrumV1Seed.name(): ElectrumV1Seed, ElectrumV2Seed.name(): ElectrumV2Seed, diff --git a/hdwallet/seeds/bip39.py b/hdwallet/seeds/bip39.py index f278d96d..111969ee 100644 --- a/hdwallet/seeds/bip39.py +++ b/hdwallet/seeds/bip39.py @@ -26,8 +26,14 @@ class BIP39Seed(ISeed): phrases and converting them into a binary seed used for hierarchical deterministic wallets. + The supplied passphrase is always used in converting the original entropy (encoded in the BIP-39 + mnemonic phrase) into the seed used to derive HD wallets. This differs from SLIP-39, which + always uses the original entropy directly (without modificiation) to derive HD wallets, and only + uses a passphrase to produce (and recover) SLIP-39 mnemonic shares. + .. note:: This class inherits from the ``ISeed`` class, thereby ensuring that all functions are accessible. + """ seed_salt_modifier: str = "mnemonic" diff --git a/hdwallet/seeds/iseed.py b/hdwallet/seeds/iseed.py index df6170ec..dda8a840 100644 --- a/hdwallet/seeds/iseed.py +++ b/hdwallet/seeds/iseed.py @@ -52,7 +52,7 @@ def is_valid(cls, seed: str) -> bool: return isinstance(seed, str) and bool(re.fullmatch( r'^[0-9a-fA-F]+$', seed - )) and len(seed) == cls.length + )) and len(seed) * 4 == cls.length def seed(self) -> str: """ diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index 71b970bc..7c36f81b 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -13,15 +13,13 @@ from ..crypto import pbkdf2_hmac_sha512 from ..exceptions import MnemonicError from ..utils import bytes_to_string -from ..mnemonics import ( - IMnemonic, BIP39Mnemonic -) +from ..mnemonics import IMnemonic from .iseed import ISeed -class SLP39Seed(ISeed): - """This class transmits the seed collected from SLIP-39 recovery. This entropy is used directly - to produce hierarchical deterministic wallets, unlike for BIP39, where the original entropy is +class SLIP39Seed(ISeed): + """This class transmits a seed collected from SLIP-39 recovery. This entropy is used /directly/ + to produce hierarchical deterministic wallets, unlike for BIP39 where the original entropy is hashed and extended to 512 bits before being used. The 3 valid seed sizes are 128, 256 and 512 bits. @@ -44,11 +42,17 @@ def name(cls) -> str: @classmethod def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None) -> str: - """ - Converts a mnemonic phrase to its corresponding seed. + """Converts a mnemonic phrase to its corresponding seed. + + The Mnemonic representation for SLIP-39 seeds is simple hex. - The Mnemonic representation for SLIP-39 seeds is simple hex. SLIP-39 seeds may be encrypted by - their own passphrase; this passphrase is the BIP-39 seed passphrase. + To support the backup and recovery of BIP-39 mnemonic phrases to/from SLIP-39, we accept a + BIP39 IMnemonic, and recover the underlying (original) entropy encoded by the BIP-39 + mnemonic phrase. In other words, you may supply a 12-word BIP39 Mnemonic like "zoo zoo + ... zoo wrong", and recover the original seed entropy 0xffff...ff. For SLIP-39 HD wallet + derivations, this seed entropy is used /directly/ to derive the wallets, unlike for BIP-39 + which hashes the entropy to extend it to 512 bits and uses the extended entropy to derive + the wallets. :param mnemonic: The mnemonic phrase to be decoded. Can be a string or an instance of `IMnemonic`. :type mnemonic: Union[str, IMnemonic] @@ -58,17 +62,7 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str :return: The decoded seed as a string. :rtype: str - """ - mnemonic = ( - mnemonic.mnemonic() if isinstance(mnemonic, IMnemonic) else mnemonic - ) - if not BIP39Mnemonic.is_valid(mnemonic=mnemonic): - raise MnemonicError(f"Invalid {cls.name()} mnemonic words") - - salt: str = unicodedata.normalize("NFKD", ( - (cls.seed_salt_modifier + passphrase) if passphrase else cls.seed_salt_modifier - )) - return bytes_to_string(pbkdf2_hmac_sha512( - password=mnemonic, salt=salt, iteration_num=cls.seed_pbkdf2_rounds - )) + """ + assert passphrase is None, "No encryption" + return mnemonic.decode() if isinstance(mnemonic, IMnemonic) else mnemonic diff --git a/hdwallet/utils.py b/hdwallet/utils.py index 9754ea5a..4302e213 100644 --- a/hdwallet/utils.py +++ b/hdwallet/utils.py @@ -499,7 +499,7 @@ def get_bytes(data: AnyStr, unhexlify: bool = True) -> bytes: else: return bytes(data, 'utf-8') else: - raise TypeError("Agreement must be either 'bytes' or 'string'!") + raise TypeError("Agreement must be either 'bytes' or 'str'!") def bytes_reverse(data: bytes) -> bytes: @@ -518,27 +518,29 @@ def bytes_reverse(data: bytes) -> bytes: return bytes(tmp) -def bytes_to_string(data: Union[bytes, str]) -> str: +def bytes_to_string(data: AnyStr, unhexlify: Optional[bool] = None) -> str: """ - Convert bytes or string data to a hexadecimal string representation. + Convert bytes or string (hexadecimal, or UTF-8 decoded) data to a hexadecimal string representation. + + If the default unhexlify == None is provided, will attempt to auto-detect non-empty hex strings, and + thus reject hex strings of accidentally odd length instead of accepting them as UTF-8 encoded binary data. :param data: The bytes or string data to convert to hexadecimal string. :type data: Union[bytes, str] + :param unhexlify: Flag indicating whether to interpret strings as hexadecimal (default None). + :type unhexlify: Optional[bool] - :return: The hexadecimal string representation of the input data. + :return: The hexadecimal string representation of the input data, empty if no data. :rtype: str + """ if not data: return '' - try: - bytes.fromhex(data) - return data - except (ValueError, TypeError): - pass - if not isinstance(data, bytes): - data = bytes(data, 'utf-8') - return data.hex() + if unhexlify is None: + unhexlify = isinstance(data, str) and all(c in string.hexdigits for c in data) + binary = get_bytes(data, unhexlify=unhexlify) + return binary.hex() def bytes_to_integer(data: bytes, endianness: Literal["little", "big"] = "big", signed: bool = False) -> int: From 33070931c5954e04a75f88d4725291c167623798 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sat, 19 Jul 2025 14:02:05 -0600 Subject: [PATCH 03/38] Initial entropies.slip39 tests pass --- hdwallet/entropies/__init__.py | 7 +++ hdwallet/entropies/slip39.py | 2 +- tests/data/json/entropies.json | 17 ++++++ .../entropies/test_entropies_slip39.py | 59 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/hdwallet/entropies/test_entropies_slip39.py diff --git a/hdwallet/entropies/__init__.py b/hdwallet/entropies/__init__.py index be60ac3f..12cddaf2 100644 --- a/hdwallet/entropies/__init__.py +++ b/hdwallet/entropies/__init__.py @@ -15,6 +15,9 @@ from .bip39 import ( BIP39Entropy, BIP39_ENTROPY_STRENGTHS ) +from .slip39 import ( + SLIP39Entropy, SLIP39_ENTROPY_STRENGTHS +) from .electrum import ( ElectrumV1Entropy, ELECTRUM_V1_ENTROPY_STRENGTHS, ElectrumV2Entropy, ELECTRUM_V2_ENTROPY_STRENGTHS @@ -41,6 +44,8 @@ class ENTROPIES: +--------------+-------------------------------------------------------------+ | BIP39 | :class:`hdwallet.entropies.bip39.BIP39Entropy` | +--------------+-------------------------------------------------------------+ + | SLIP39 | :class:`hdwallet.entropies.bip39.SLIP39Entropy` | + +--------------+-------------------------------------------------------------+ | Electrum-V1 | :class:`hdwallet.entropies.electrum.v1.ElectrumV1Entropy` | +--------------+-------------------------------------------------------------+ | Electrum-V2 | :class:`hdwallet.entropies.electrum.v2.ElectrumV2Entropy` | @@ -52,6 +57,7 @@ class ENTROPIES: dictionary: Dict[str, Type[IEntropy]] = { AlgorandEntropy.name(): AlgorandEntropy, BIP39Entropy.name(): BIP39Entropy, + SLIP39Entropy.name(): SLIP39Entropy, ElectrumV1Entropy.name(): ElectrumV1Entropy, ElectrumV2Entropy.name(): ElectrumV2Entropy, MoneroEntropy.name(): MoneroEntropy @@ -116,6 +122,7 @@ def is_entropy(cls, name: str) -> bool: "IEntropy", "ALGORAND_ENTROPY_STRENGTHS", "BIP39_ENTROPY_STRENGTHS", + "SLIP39_ENTROPY_STRENGTHS", "ELECTRUM_V1_ENTROPY_STRENGTHS", "ELECTRUM_V2_ENTROPY_STRENGTHS", "MONERO_ENTROPY_STRENGTHS", diff --git a/hdwallet/entropies/slip39.py b/hdwallet/entropies/slip39.py index c862c025..0d4b8bf5 100644 --- a/hdwallet/entropies/slip39.py +++ b/hdwallet/entropies/slip39.py @@ -13,7 +13,7 @@ class SLIP39_ENTROPY_STRENGTHS: """ ONE_HUNDRED_TWENTY_EIGHT: int = 128 TWO_HUNDRED_FIFTY_SIX: int = 256 - FIVE_HUNDRED_TWELVE: int = 256 + FIVE_HUNDRED_TWELVE: int = 512 class SLIP39Entropy(IEntropy): diff --git a/tests/data/json/entropies.json b/tests/data/json/entropies.json index f7991992..32563150 100644 --- a/tests/data/json/entropies.json +++ b/tests/data/json/entropies.json @@ -33,6 +33,23 @@ "strength": 256 } }, + "SLIP39": { + "128": { + "name": "SLIP39", + "entropy": "ffffffffffffffffffffffffffffffff", + "strength": 128 + }, + "256": { + "name": "SLIP39", + "entropy": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "strength": 256 + }, + "512": { + "name": "SLIP39", + "entropy": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "strength": 512 + } + }, "Electrum-V1": { "128": { "name": "Electrum-V1", diff --git a/tests/hdwallet/entropies/test_entropies_slip39.py b/tests/hdwallet/entropies/test_entropies_slip39.py new file mode 100644 index 00000000..44a3e9a4 --- /dev/null +++ b/tests/hdwallet/entropies/test_entropies_slip39.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2024, Meheret Tesfaye Batu +# 2024, Eyoel Tadesse +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +import json +import os +import pytest + +from hdwallet.entropies.slip39 import ( + SLIP39Entropy, SLIP39_ENTROPY_STRENGTHS +) +from hdwallet.utils import get_bytes +from hdwallet.exceptions import EntropyError + + +def test_slip39_entropy(data): + + assert SLIP39_ENTROPY_STRENGTHS.ONE_HUNDRED_TWENTY_EIGHT == 128 + assert SLIP39_ENTROPY_STRENGTHS.TWO_HUNDRED_FIFTY_SIX == 256 + assert SLIP39_ENTROPY_STRENGTHS.FIVE_HUNDRED_TWELVE == 512 + + assert SLIP39Entropy.is_valid_strength(strength=SLIP39_ENTROPY_STRENGTHS.ONE_HUNDRED_TWENTY_EIGHT) + assert SLIP39Entropy.is_valid_strength(strength=SLIP39_ENTROPY_STRENGTHS.TWO_HUNDRED_FIFTY_SIX) + assert SLIP39Entropy.is_valid_strength(strength=SLIP39_ENTROPY_STRENGTHS.FIVE_HUNDRED_TWELVE) + + assert SLIP39Entropy.is_valid_bytes_strength(bytes_strength=len(get_bytes(data["entropies"]["SLIP39"]["128"]["entropy"]))) + assert SLIP39Entropy.is_valid_bytes_strength(bytes_strength=len(get_bytes(data["entropies"]["SLIP39"]["256"]["entropy"]))) + assert SLIP39Entropy.is_valid_bytes_strength(bytes_strength=len(get_bytes(data["entropies"]["SLIP39"]["512"]["entropy"]))) + + assert SLIP39Entropy(entropy=SLIP39Entropy.generate(strength=SLIP39_ENTROPY_STRENGTHS.ONE_HUNDRED_TWENTY_EIGHT)).strength() == 128 + assert SLIP39Entropy(entropy=SLIP39Entropy.generate(strength=SLIP39_ENTROPY_STRENGTHS.TWO_HUNDRED_FIFTY_SIX)).strength() == 256 + assert SLIP39Entropy(entropy=SLIP39Entropy.generate(strength=SLIP39_ENTROPY_STRENGTHS.FIVE_HUNDRED_TWELVE)).strength() == 512 + + slip39_128 = SLIP39Entropy(entropy=data["entropies"]["SLIP39"]["128"]["entropy"]) + slip39_256 = SLIP39Entropy(entropy=data["entropies"]["SLIP39"]["256"]["entropy"]) + slip39_512 = SLIP39Entropy(entropy=data["entropies"]["SLIP39"]["512"]["entropy"]) + + assert slip39_128.name() == data["entropies"]["SLIP39"]["128"]["name"] + assert slip39_256.name() == data["entropies"]["SLIP39"]["256"]["name"] + assert slip39_512.name() == data["entropies"]["SLIP39"]["512"]["name"] + + assert slip39_128.strength() == data["entropies"]["SLIP39"]["128"]["strength"] + assert slip39_256.strength() == data["entropies"]["SLIP39"]["256"]["strength"] + assert slip39_512.strength() == data["entropies"]["SLIP39"]["512"]["strength"] + + assert slip39_128.entropy() == data["entropies"]["SLIP39"]["128"]["entropy"] + assert slip39_256.entropy() == data["entropies"]["SLIP39"]["256"]["entropy"] + assert slip39_512.entropy() == data["entropies"]["SLIP39"]["512"]["entropy"] + + with pytest.raises(EntropyError, match="Invalid entropy data"): + SLIP39Entropy(entropy="INVALID_ENTROPY") + + with pytest.raises(EntropyError, match="Invalid entropy data"): + SLIP39Entropy(entropy="f"*(512//4-1)) + with pytest.raises(EntropyError, match="Unsupported entropy strength"): + SLIP39Entropy(entropy="f"*(512//4-2)) From 3f16a2180ebd37daf4a0b9acea10226d3a9069f6 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sun, 20 Jul 2025 09:49:01 -0600 Subject: [PATCH 04/38] Progress toward SLIP39 entropy --- hdwallet/mnemonics/algorand/mnemonic.py | 15 ----------- hdwallet/mnemonics/bip39/mnemonic.py | 17 ------------ hdwallet/mnemonics/electrum/v1/mnemonic.py | 15 ----------- hdwallet/mnemonics/electrum/v2/mnemonic.py | 15 ----------- hdwallet/mnemonics/imnemonic.py | 13 +++++++--- hdwallet/mnemonics/monero/mnemonic.py | 15 ----------- hdwallet/seeds/bip39.py | 2 ++ hdwallet/seeds/slip39.py | 7 ++--- tests/data/json/seeds.json | 10 +++++++- tests/data/raw/strengths.txt | 7 +++++ tests/hdwallet/seeds/test_seeds_slip39.py | 30 ++++++++++++++++++++++ 11 files changed, 59 insertions(+), 87 deletions(-) create mode 100644 tests/hdwallet/seeds/test_seeds_slip39.py diff --git a/hdwallet/mnemonics/algorand/mnemonic.py b/hdwallet/mnemonics/algorand/mnemonic.py index 053dfa01..bfef6fa8 100644 --- a/hdwallet/mnemonics/algorand/mnemonic.py +++ b/hdwallet/mnemonics/algorand/mnemonic.py @@ -197,18 +197,3 @@ def decode(cls, mnemonic: str, **kwargs) -> str: ) return bytes_to_string(entropy) - - @classmethod - def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: - """ - Normalizes the given mnemonic by splitting it into a list of words if it is a string. - - :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. - :type mnemonic: Union[str, List[str]] - - :return: A list of words from the mnemonic. - :rtype: List[str] - """ - - mnemonic: list = mnemonic.split() if isinstance(mnemonic, str) else mnemonic - return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index 1dc39f9f..aa8423d8 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -8,8 +8,6 @@ Union, Dict, List, Optional ) -import unicodedata - from ...entropies import ( IEntropy, BIP39Entropy, BIP39_ENTROPY_STRENGTHS ) @@ -348,18 +346,3 @@ def is_valid( return True except (Error, KeyError): return False - - @classmethod - def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: - """ - Normalizes the given mnemonic by splitting it into a list of words if it is a string. - - :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. - :type mnemonic: Union[str, List[str]] - - :return: A list of words from the mnemonic. - :rtype: List[str] - """ - - mnemonic: list = mnemonic.split() if isinstance(mnemonic, str) else mnemonic - return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) diff --git a/hdwallet/mnemonics/electrum/v1/mnemonic.py b/hdwallet/mnemonics/electrum/v1/mnemonic.py index 18424613..ed84665b 100644 --- a/hdwallet/mnemonics/electrum/v1/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v1/mnemonic.py @@ -254,18 +254,3 @@ def is_valid( return True except (ValueError, KeyError, MnemonicError): return False - - @classmethod - def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: - """ - Normalizes the given mnemonic by splitting it into a list of words if it is a string. - - :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. - :type mnemonic: Union[str, List[str]] - - :return: A list of words from the mnemonic. - :rtype: List[str] - """ - - mnemonic: list = mnemonic.split() if isinstance(mnemonic, str) else mnemonic - return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) diff --git a/hdwallet/mnemonics/electrum/v2/mnemonic.py b/hdwallet/mnemonics/electrum/v2/mnemonic.py index 8949dc07..1aa69d7c 100644 --- a/hdwallet/mnemonics/electrum/v2/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v2/mnemonic.py @@ -430,18 +430,3 @@ def mnemonic_type(self) -> str: """ return self._mnemonic_type - - @classmethod - def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: - """ - Normalizes the given mnemonic by splitting it into a list of words if it is a string. - - :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. - :type mnemonic: Union[str, List[str]] - - :return: A list of words from the mnemonic. - :rtype: List[str] - """ - - mnemonic: list = mnemonic.split() if isinstance(mnemonic, str) else mnemonic - return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 5522448d..51e4b0b1 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -12,6 +12,7 @@ ) import os +import unicodedata from ..exceptions import MnemonicError from ..entropies import IEntropy @@ -204,12 +205,12 @@ def is_valid_language(cls, language: str) -> bool: @classmethod def is_valid_words(cls, words: int) -> bool: """ - Checks if the given words is valid. + Checks if the given number of words is valid. - :param words: The words to check. + :param words: The number of words to check. :type words: int - :return: True if the strength is valid, False otherwise. + :return: True if the number of mnemonic words is valid, False otherwise. :rtype: bool """ @@ -218,13 +219,17 @@ def is_valid_words(cls, words: int) -> bool: @classmethod def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: """ + Normalizes the given mnemonic by splitting it into a list of words if it is a string. + Resilient to extra whitespace and down-cases uppercase symbols. :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. :type mnemonic: Union[str, List[str]] :return: A list of words from the mnemonic. :rtype: List[str] + """ + mnemonic: list = mnemonic.split() if isinstance(mnemonic, str) else mnemonic + return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) - return mnemonic.split() if isinstance(mnemonic, str) else mnemonic diff --git a/hdwallet/mnemonics/monero/mnemonic.py b/hdwallet/mnemonics/monero/mnemonic.py index 3d968952..2576664e 100644 --- a/hdwallet/mnemonics/monero/mnemonic.py +++ b/hdwallet/mnemonics/monero/mnemonic.py @@ -300,18 +300,3 @@ def decode(cls, mnemonic: str, **kwargs) -> str: word_1, word_2, word_3, words_list, "little" ) return bytes_to_string(entropy) - - @classmethod - def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: - """ - Normalizes the given mnemonic by splitting it into a list of words if it is a string. - - :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. - :type mnemonic: Union[str, List[str]] - - :return: A list of words from the mnemonic. - :rtype: List[str] - """ - - mnemonic: list = mnemonic.split() if isinstance(mnemonic, str) else mnemonic - return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) diff --git a/hdwallet/seeds/bip39.py b/hdwallet/seeds/bip39.py index 111969ee..21cfb7f8 100644 --- a/hdwallet/seeds/bip39.py +++ b/hdwallet/seeds/bip39.py @@ -57,6 +57,8 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str """ Converts a mnemonic phrase to its corresponding seed. + BIP39 stretches a prefix + (passphrase or "") + normalized mnemonic to produce the 512-bit seed. + :param mnemonic: The mnemonic phrase to be decoded. Can be a string or an instance of `IMnemonic`. :type mnemonic: Union[str, IMnemonic] diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index 7c36f81b..17dea5fd 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -10,9 +10,6 @@ import unicodedata -from ..crypto import pbkdf2_hmac_sha512 -from ..exceptions import MnemonicError -from ..utils import bytes_to_string from ..mnemonics import IMnemonic from .iseed import ISeed @@ -64,5 +61,5 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str :rtype: str """ - assert passphrase is None, "No encryption" - return mnemonic.decode() if isinstance(mnemonic, IMnemonic) else mnemonic + + return mnemonic.decode(mnemonic._mnemonic) if isinstance(mnemonic, IMnemonic) else mnemonic diff --git a/tests/data/json/seeds.json b/tests/data/json/seeds.json index b874c122..f6ad8250 100644 --- a/tests/data/json/seeds.json +++ b/tests/data/json/seeds.json @@ -500,6 +500,14 @@ } } }, + "SLIP39": { + "12": { + "english": { + "mnemonic": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "seed": "ffffffffffffffffffffffffffffffff" + } + } + }, "Cardano": { "12": { "byron-icarus": { @@ -2919,4 +2927,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/data/raw/strengths.txt b/tests/data/raw/strengths.txt index 1aaf58c4..19735481 100644 --- a/tests/data/raw/strengths.txt +++ b/tests/data/raw/strengths.txt @@ -12,6 +12,13 @@ BIP39 Strengths 256 +SLIP39 Strengths +------------------ +128 +256 +512 + + Electrum-V1 Strengths ----------------------- 128 diff --git a/tests/hdwallet/seeds/test_seeds_slip39.py b/tests/hdwallet/seeds/test_seeds_slip39.py new file mode 100644 index 00000000..a2aed961 --- /dev/null +++ b/tests/hdwallet/seeds/test_seeds_slip39.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2024, Meheret Tesfaye Batu +# 2024, Eyoel Tadesse +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +import json +import logging +import os +import pytest + +from hdwallet.seeds.slip39 import SLIP39Seed +from hdwallet.mnemonics.bip39 import BIP39Mnemonic + + +def test_slip39_seeds(data): + + for words in data["seeds"]["SLIP39"].keys(): + for lang in data["seeds"]["SLIP39"][words].keys(): + mnemonic = data["seeds"]["SLIP39"][words][lang]["mnemonic"] + try: + mnemonic = BIP39Mnemonic(mnemonic=mnemonic) + except Exception: + logging.exception("Failed to interpret %s as BIP-39 Mnemonic", mnemonic) + pass + assert SLIP39Seed.from_mnemonic( + mnemonic = mnemonic + ) == data["seeds"]["SLIP39"][words][lang]["seed"] + From 393394540aa05e96619c8818b4e2daa829a094fe Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sun, 20 Jul 2025 10:02:06 -0600 Subject: [PATCH 05/38] Test BIP-39 and raw hex SLIP-39 seeds --- tests/data/json/seeds.json | 20 ++++++++++++++++++-- tests/hdwallet/seeds/test_seeds_slip39.py | 8 +++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/data/json/seeds.json b/tests/data/json/seeds.json index f6ad8250..990c3ad7 100644 --- a/tests/data/json/seeds.json +++ b/tests/data/json/seeds.json @@ -504,9 +504,25 @@ "12": { "english": { "mnemonic": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", - "seed": "ffffffffffffffffffffffffffffffff" + "non-passphrase-seed": "ffffffffffffffffffffffffffffffff", + "passphrases": null + }, + "hex 128": { + "mnemonic": "ffffffffffffffffffffffffffffffff", + "non-passphrase-seed": "ffffffffffffffffffffffffffffffff", + "passphrases": null + }, + "hex 256": { + "mnemonic": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "passphrases": null + }, + "hex 512": { + "mnemonic": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "passphrases": null } - } + } }, "Cardano": { "12": { diff --git a/tests/hdwallet/seeds/test_seeds_slip39.py b/tests/hdwallet/seeds/test_seeds_slip39.py index a2aed961..cba7ef1f 100644 --- a/tests/hdwallet/seeds/test_seeds_slip39.py +++ b/tests/hdwallet/seeds/test_seeds_slip39.py @@ -19,6 +19,12 @@ def test_slip39_seeds(data): for words in data["seeds"]["SLIP39"].keys(): for lang in data["seeds"]["SLIP39"][words].keys(): mnemonic = data["seeds"]["SLIP39"][words][lang]["mnemonic"] + # A SLIP-39 "backup" for another Mnemonic (eg. BIP-39) backs up the original entropy + # (not the derived seed, which will often be stretched to a different length). SLIP-39 + # /can/ store 512-bit data (the output of a BIP-39 seed, after hashing it with its + # passphrase, but this is not generally supported by hardware wallets supporting + # SLIP-39, such as the Trezor. This is unfortunate, as it prevents backing up BIP-39 + # derived seeds including the passphrase. try: mnemonic = BIP39Mnemonic(mnemonic=mnemonic) except Exception: @@ -26,5 +32,5 @@ def test_slip39_seeds(data): pass assert SLIP39Seed.from_mnemonic( mnemonic = mnemonic - ) == data["seeds"]["SLIP39"][words][lang]["seed"] + ) == data["seeds"]["SLIP39"][words][lang]["non-passphrase-seed"] From 6f93825f5175220f8796d870a209e383b22d1d60 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Fri, 15 Aug 2025 11:49:55 -0600 Subject: [PATCH 06/38] Get Nix and venv environment automation and install working --- Makefile | 80 +++++++++++++++++++++++++++++++++--- default.nix | 92 ++++++++++++++++++++++++++++++++++++++++++ nixpkgs.nix | 4 ++ requirements.txt | 2 +- requirements/cli.txt | 2 +- requirements/dev.txt | 3 ++ requirements/docs.txt | 2 +- requirements/tests.txt | 2 +- setup.py | 2 +- shell.nix | 19 +++++++++ 10 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 default.nix create mode 100644 nixpkgs.nix create mode 100644 requirements/dev.txt create mode 100644 shell.nix diff --git a/Makefile b/Makefile index b97de95f..f5e29f22 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,86 @@ -# Minimal makefile for Sphinx documentation +# Minimal makefile for Sphinx documentation, Nix and venv # +SHELL := /bin/bash + # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = docs -BUILDDIR = build +export SPHINXOPTS ?= +export SPHINXBUILD ?= sphinx-build +export SOURCEDIR = docs +export BUILDDIR = build + +export PYTHON ?= $(shell python3 --version >/dev/null 2>&1 && echo python3 || echo python ) + +# Ensure $(PYTHON), $(VENV) are re-evaluated at time of expansion, when target 'python' and 'poetry' are known to be available +PYTHON_V = $(shell $(PYTHON) -c "import sys; print('-'.join((('venv' if sys.prefix != sys.base_prefix else next(iter(filter(None,sys.base_prefix.split('/'))))),sys.platform,sys.implementation.cache_tag)))" 2>/dev/null ) + +VERSION = $(shell $(PYTHON) -c "exec(open('hdwallet/info.py').read()); print(__version__[1:])" ) +WHEEL = dist/hdwallet-$(VERSION)-py3-none-any.whl +VENV = $(CURDIR)-$(VERSION)-$(PYTHON_V) + +# Force export of variables that might be set from command line +export VENV_OPTS ?= # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: help Makefile +.PHONY: help wheel install venv Makefile FORCE + + +wheel: $(WHEEL) + +$(WHEEL): FORCE + $(PYTHON) -m build + @ls -last dist + +# Install from wheel, including all optional extra dependencies (doesn't include dev) +install: $(WHEEL) FORCE + $(PYTHON) -m pip install $<[cli,docs,tests] + +# Install from requirements/*; eg. install-dev +install-%: FORCE + $(PYTHON) -m pip install -r requirements/$*.txt + +# +# Nix and VirtualEnv build, install and activate +# +# Create, start and run commands in "interactive" shell with a python venv's activate init-file. +# Doesn't allow recursive creation of a venv with a venv-supplied python. Alters the bin/activate +# to include the user's .bashrc (eg. Git prompts, aliases, ...). Use to run Makefile targets in a +# proper context, for example to obtain a Nix environment containing the proper Python version, +# create a python venv with the current Python environment. +# +# make nix-venv-build +# +nix-%: + @if [ -r flake.nix ]; then \ + nix develop $(NIX_OPTS) --command make $*; \ + else \ + nix-shell $(NIX_OPTS) --run "make $*"; \ + fi + +venv-%: $(VENV) + @echo; echo "*** Running in $< VirtualEnv: make $*" + @bash --init-file $=1.4.1,<2 coincurve>=20.0.0,<21 pynacl>=1.5.0,<2 base58>=2.1.1,<3 -cbor2>=5.6.1,<6 \ No newline at end of file +cbor2>=5.6.1,<6 diff --git a/requirements/cli.txt b/requirements/cli.txt index a48dbd9b..7b964544 100644 --- a/requirements/cli.txt +++ b/requirements/cli.txt @@ -1,4 +1,4 @@ click>=8.1.7,<9 click-aliases>=1.0.5,<2 tabulate>=0.9.0,<1 -bip38>=1.4.1,<2 \ No newline at end of file +bip38>=1.4.1,<2 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 00000000..02ac8952 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,3 @@ +build +setuptools +wheel diff --git a/requirements/docs.txt b/requirements/docs.txt index abd6441a..fcd4c94d 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,3 +1,3 @@ sphinx>=8.1.3,<9 sphinx-click>=6.0.0,<7 -furo==2024.8.6 \ No newline at end of file +furo==2024.8.6 diff --git a/requirements/tests.txt b/requirements/tests.txt index 8c5ae297..3cb77c0a 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,3 +1,3 @@ pytest>=8.3.2,<9 coverage>=7.6.4,<8 -tox>=4.23.2,<5 \ No newline at end of file +tox>=4.23.2,<5 diff --git a/setup.py b/setup.py index 386db809..b34cdaf7 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ # requirements/{name}.txt def get_requirements(name: str) -> List[str]: with open(f"{name}.txt", "r") as requirements: - return list(map(str.strip, requirements.read().split("\n"))) + return list(filter(None, map(str.strip, requirements.read().split("\n")))) # README.md diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..57fba898 --- /dev/null +++ b/shell.nix @@ -0,0 +1,19 @@ +{ pkgs ? import ./nixpkgs.nix {} }: + +let + targets = import ./default.nix { + inherit pkgs; + }; + targeted = builtins.getEnv "TARGET"; + selected = targeted + pkgs.lib.optionalString (targeted == "") "py313"; +in + +with pkgs; + +mkShell { + buildInputs = lib.getAttrFromPath [ selected "buildInputs" ] targets; + + shellHook = '' + echo "Welcome to the Python ${selected} environment!" + ''; +} From efb9b945f2ac28b0a48d4d1e1f35fa1d80095d53 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sat, 16 Aug 2025 08:08:33 -0600 Subject: [PATCH 07/38] Seeds support raw entropy generally; support SLIP39 seeds --- .gitignore | 3 ++ Makefile | 3 +- flake.nix | 60 ++++++++++++++++++++++++++++ hdwallet/cli/generate/seed.py | 13 ++++-- hdwallet/mnemonics/__init__.py | 4 +- hdwallet/mnemonics/bip39/mnemonic.py | 2 +- hdwallet/mnemonics/imnemonic.py | 12 +++++- hdwallet/seeds/bip39.py | 7 ++-- hdwallet/seeds/slip39.py | 38 ++++++++++++++---- hdwallet/utils.py | 8 ++-- 10 files changed, 125 insertions(+), 25 deletions(-) create mode 100644 flake.nix diff --git a/.gitignore b/.gitignore index e61d52c9..59d156be 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ __pycache__/ # py.test stuff .pytest_cache/ + +# NixOS stuff +flake.lock diff --git a/Makefile b/Makefile index f5e29f22..0074fecc 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,7 @@ VENV = $(CURDIR)-$(VERSION)-$(PYTHON_V) # Force export of variables that might be set from command line export VENV_OPTS ?= +export NIX_OPTS ?= # Put it first so that "make" without argument is like "make help". help: @@ -37,7 +38,7 @@ $(WHEEL): FORCE # Install from wheel, including all optional extra dependencies (doesn't include dev) install: $(WHEEL) FORCE - $(PYTHON) -m pip install $<[cli,docs,tests] + $(PYTHON) -m pip install --force-reinstall $<[cli,docs,tests] # Install from requirements/*; eg. install-dev install-%: FORCE diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..67e8fd6f --- /dev/null +++ b/flake.nix @@ -0,0 +1,60 @@ +{ + description = "Python HD Wallet development environment with multiple Python versions"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/25.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Create Python environments with required packages + mkPythonEnv = pythonPkg: pythonPkg.withPackages (ps: with ps; [ + pytest + coincurve + scikit-learn + pycryptodome + pynacl + ]); + + python310Env = mkPythonEnv pkgs.python310; + python311Env = mkPythonEnv pkgs.python311; + python312Env = mkPythonEnv pkgs.python312; + python313Env = mkPythonEnv pkgs.python313; + + in { + # Single development shell with all Python versions + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + # Common tools + cacert + git + gnumake + openssh + bash + bash-completion + + # All Python versions with packages + #python310Env + python311Env + python312Env + python313Env + ]; + + shellHook = '' + echo "Welcome to the multi-Python development environment!" + echo "Available Python interpreters:" + echo " python (default): $(python --version 2>&1 || echo 'not available')" + #echo " python3.10: $(python3.10 --version 2>&1 || echo 'not available')" + echo " python3.11: $(python3.11 --version 2>&1 || echo 'not available')" + echo " python3.12: $(python3.12 --version 2>&1 || echo 'not available')" + echo " python3.13: $(python3.13 --version 2>&1 || echo 'not available')" + echo "" + echo "All versions have pytest, coincurve, scikit-learn, pycryptodome, and pynacl installed." + ''; + }; + }); +} diff --git a/hdwallet/cli/generate/seed.py b/hdwallet/cli/generate/seed.py index e47b7166..fb944cc7 100644 --- a/hdwallet/cli/generate/seed.py +++ b/hdwallet/cli/generate/seed.py @@ -10,7 +10,7 @@ from ...mnemonics import MNEMONICS from ...seeds import ( - ISeed, BIP39Seed, CardanoSeed, ElectrumV2Seed, SEEDS + ISeed, BIP39Seed, SLIP39Seed, CardanoSeed, ElectrumV2Seed, SEEDS ) @@ -26,7 +26,7 @@ def generate_seed(**kwargs) -> None: ), err=True) sys.exit() - if kwargs.get("client") == "Electrum-V2": + if kwargs.get("client") == ElectrumV2Seed.name(): if not MNEMONICS.mnemonic(name="Electrum-V2").is_valid( mnemonic=kwargs.get("mnemonic"), mnemonic_type=kwargs.get("mnemonic_type") ): @@ -35,7 +35,7 @@ def generate_seed(**kwargs) -> None: else: mnemonic_name: str = "BIP39" if kwargs.get("client") == CardanoSeed.name() else kwargs.get("client") if not MNEMONICS.mnemonic(name=mnemonic_name).is_valid(mnemonic=kwargs.get("mnemonic")): - click.echo(click.style(f"Invalid {mnemonic_name} mnemonic"), err=True) + click.echo(click.style(f"Invalid {mnemonic_name} mnemonic {kwargs.get('mnemonic')!r}"), err=True) sys.exit() @@ -46,6 +46,13 @@ def generate_seed(**kwargs) -> None: passphrase=kwargs.get("passphrase") ) ) + elif kwargs.get("client") == SLIP39Seed.name(): + seed: ISeed = SLIP39Seed( + seed=SLIP39Seed.from_mnemonic( + mnemonic=kwargs.get("mnemonic"), + passphrase=kwargs.get("passphrase") + ) + ) elif kwargs.get("client") == CardanoSeed.name(): seed: ISeed = CardanoSeed( seed=CardanoSeed.from_mnemonic( diff --git a/hdwallet/mnemonics/__init__.py b/hdwallet/mnemonics/__init__.py index 50186dfa..01a32751 100644 --- a/hdwallet/mnemonics/__init__.py +++ b/hdwallet/mnemonics/__init__.py @@ -54,7 +54,8 @@ class MNEMONICS: BIP39Mnemonic.name(): BIP39Mnemonic, ElectrumV1Mnemonic.name(): ElectrumV1Mnemonic, ElectrumV2Mnemonic.name(): ElectrumV2Mnemonic, - MoneroMnemonic.name(): MoneroMnemonic + MoneroMnemonic.name(): MoneroMnemonic, + "SLIP39": BIP39Mnemonic, } @classmethod @@ -120,7 +121,6 @@ def is_mnemonic(cls, name) -> bool: "ELECTRUM_V1_MNEMONIC_WORDS", "ELECTRUM_V1_MNEMONIC_LANGUAGES", "ELECTRUM_V2_MNEMONIC_WORDS", "ELECTRUM_V2_MNEMONIC_LANGUAGES", "ELECTRUM_V2_MNEMONIC_TYPES", "MONERO_MNEMONIC_WORDS", "MONERO_MNEMONIC_LANGUAGES", - "SLIP39_MNEMONIC_WORDS", "SLIP39_MNEMONIC_LANGUAGES", "MNEMONICS" ] + [ cls.__name__ for cls in MNEMONICS.classes() diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index aa8423d8..f0c26579 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -217,7 +217,7 @@ def encode(cls, entropy: Union[str, bytes], language: str) -> str: :rtype: str """ - entropy: bytes = get_bytes(entropy) + entropy: bytes = get_bytes(entropy, unhexlify=True) if not BIP39Entropy.is_valid_bytes_strength(len(entropy)): raise EntropyError( "Wrong entropy strength", expected=BIP39Entropy.strengths, got=(len(entropy) * 8) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 51e4b0b1..7c5b0991 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -12,6 +12,7 @@ ) import os +import string import unicodedata from ..exceptions import MnemonicError @@ -219,10 +220,14 @@ def is_valid_words(cls, words: int) -> bool: @classmethod def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: """ - Normalizes the given mnemonic by splitting it into a list of words if it is a string. Resilient to extra whitespace and down-cases uppercase symbols. + Recognizes hex strings (raw entropy), and attempts to normalize them as appropriate for the + IMnemonic-derived class using 'from_entropy'. Thus, all IMnemonics can accept either + mnemonic strings, or raw hex-encoded entropy, if they use the IMnemonic.normalize base + method in their derived 'decode' and 'is_valid' implementations. + :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. :type mnemonic: Union[str, List[str]] @@ -230,6 +235,9 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: :rtype: List[str] """ - mnemonic: list = mnemonic.split() if isinstance(mnemonic, str) else mnemonic + if isinstance(mnemonic, str): + if all(c in string.hexdigits for c in mnemonic.strip()): + mnemonic: str = cls.from_entropy(mnemonic, language="english") + mnemonic: list = mnemonic.split() return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) diff --git a/hdwallet/seeds/bip39.py b/hdwallet/seeds/bip39.py index 21cfb7f8..c7d84ca9 100644 --- a/hdwallet/seeds/bip39.py +++ b/hdwallet/seeds/bip39.py @@ -26,10 +26,9 @@ class BIP39Seed(ISeed): phrases and converting them into a binary seed used for hierarchical deterministic wallets. - The supplied passphrase is always used in converting the original entropy (encoded in the BIP-39 - mnemonic phrase) into the seed used to derive HD wallets. This differs from SLIP-39, which - always uses the original entropy directly (without modificiation) to derive HD wallets, and only - uses a passphrase to produce (and recover) SLIP-39 mnemonic shares. + The supplied passphrase is always used in extending the original entropy + (encoded in the BIP-39 mnemonic phrase) into the 512-bit seed used to derive + HD wallets. .. note:: This class inherits from the ``ISeed`` class, thereby ensuring that all functions are accessible. diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index 17dea5fd..6cfe9c0e 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -11,6 +11,7 @@ import unicodedata from ..mnemonics import IMnemonic +from ..mnemonics.bip39 import BIP39Mnemonic from .iseed import ISeed @@ -39,17 +40,18 @@ def name(cls) -> str: @classmethod def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None) -> str: - """Converts a mnemonic phrase to its corresponding seed. + """Converts a mnemonic phrase to its corresponding raw entropy. The Mnemonic representation for SLIP-39 seeds is simple hex. To support the backup and recovery of BIP-39 mnemonic phrases to/from SLIP-39, we accept a - BIP39 IMnemonic, and recover the underlying (original) entropy encoded by the BIP-39 - mnemonic phrase. In other words, you may supply a 12-word BIP39 Mnemonic like "zoo zoo - ... zoo wrong", and recover the original seed entropy 0xffff...ff. For SLIP-39 HD wallet - derivations, this seed entropy is used /directly/ to derive the wallets, unlike for BIP-39 - which hashes the entropy to extend it to 512 bits and uses the extended entropy to derive - the wallets. + BIP39 IMnemonic or mnemonic phrase, and recover the underlying (original) entropy encoded by + the BIP-39 mnemonic phrase. + + In other words, you may supply a 12-word BIP39 Mnemonic like "zoo zoo ... zoo wrong", and + recover the original seed entropy 0xffff...ff. For SLIP-39 HD wallet derivations, this seed + entropy is used /directly/ to derive the wallets, unlike for BIP-39 which hashes the entropy + to extend it to 512 bits and uses the extended entropy to derive the wallets. :param mnemonic: The mnemonic phrase to be decoded. Can be a string or an instance of `IMnemonic`. :type mnemonic: Union[str, IMnemonic] @@ -62,4 +64,24 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str """ - return mnemonic.decode(mnemonic._mnemonic) if isinstance(mnemonic, IMnemonic) else mnemonic + if not isinstance(mnemonic, IMnemonic): + # Not an IMnemonic; must be a str. Try the supported mnemonic encodings we'll allow for + # SLIP39 seeds, converting the mnemonic phrase to an IMnemonic if recognized. + # + # TODO: Eventually add SLIP-39. + allowed_entropy = [ + BIP39Mnemonic, + # SLIP39Mnemonic, ... + ] + + for M in allowed_entropy: + if M.is_valid(mnemonic): + mnemonic = M(mnemonic=mnemonic) + break + else: + raise EntropyError( + "Invalid entropy instance", expected=[str, ] + allowed_entropy, got=type(mnemonic) + ) + + # Some kind of IMnemonic (eg. a BIP39Mnemonic); get and return its raw entropy as hex + return mnemonic.decode(mnemonic.mnemonic()) diff --git a/hdwallet/utils.py b/hdwallet/utils.py index 4302e213..e82f2bcf 100644 --- a/hdwallet/utils.py +++ b/hdwallet/utils.py @@ -519,11 +519,11 @@ def bytes_reverse(data: bytes) -> bytes: def bytes_to_string(data: AnyStr, unhexlify: Optional[bool] = None) -> str: - """ - Convert bytes or string (hexadecimal, or UTF-8 decoded) data to a hexadecimal string representation. + """Convert bytes or string (hexadecimal, or UTF-8 decoded) data to a hexadecimal string representation. - If the default unhexlify == None is provided, will attempt to auto-detect non-empty hex strings, and - thus reject hex strings of accidentally odd length instead of accepting them as UTF-8 encoded binary data. + If the default unhexlify == None is provided, will attempt to auto-detect non-empty hex strings, + and thus reject hex strings of accidentally odd length instead of accepting them (surprisingly + and almost certainly incorrectly!) as UTF-8 encoded binary data. :param data: The bytes or string data to convert to hexadecimal string. :type data: Union[bytes, str] From 729ad1c3e0b525cf49ba75feaebf39d75d7d9b24 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 18 Aug 2025 13:14:02 -0600 Subject: [PATCH 08/38] Begin to implement SLIP39 mnemonics w/ language specifying specs --- MANIFEST.in | 5 + Makefile | 6 +- hdwallet/cli/generate/seed.py | 6 +- hdwallet/mnemonics/slip39/__init__.py | 16 + hdwallet/mnemonics/slip39/mnemonic.py | 425 ++++++++++++++++++ setup.py | 3 +- .../mnemonics/test_mnemonics_slip39.py | 50 +++ 7 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 hdwallet/mnemonics/slip39/__init__.py create mode 100644 hdwallet/mnemonics/slip39/mnemonic.py create mode 100644 tests/hdwallet/mnemonics/test_mnemonics_slip39.py diff --git a/MANIFEST.in b/MANIFEST.in index bffbb377..832de8e3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,11 @@ include LICENSE include README.md include requirements.txt +include requirements/slip39.txt +include requirements/cli.txt +include requirements/tests.txt +include requirements/docs.txt +include requirements/dev.txt recursive-include hdwallet/mnemonics/algorand/wordlist *.txt recursive-include hdwallet/mnemonics/bip39/wordlist *.txt diff --git a/Makefile b/Makefile index 0074fecc..b563afa2 100644 --- a/Makefile +++ b/Makefile @@ -38,12 +38,16 @@ $(WHEEL): FORCE # Install from wheel, including all optional extra dependencies (doesn't include dev) install: $(WHEEL) FORCE - $(PYTHON) -m pip install --force-reinstall $<[cli,docs,tests] + $(PYTHON) -m pip install --force-reinstall $<[slip39,cli,docs,tests] # Install from requirements/*; eg. install-dev install-%: FORCE $(PYTHON) -m pip install -r requirements/$*.txt + +unit-%: + $(PYTHON) -m pytest -k $* + # # Nix and VirtualEnv build, install and activate # diff --git a/hdwallet/cli/generate/seed.py b/hdwallet/cli/generate/seed.py index fb944cc7..a144c9cb 100644 --- a/hdwallet/cli/generate/seed.py +++ b/hdwallet/cli/generate/seed.py @@ -36,9 +36,13 @@ def generate_seed(**kwargs) -> None: mnemonic_name: str = "BIP39" if kwargs.get("client") == CardanoSeed.name() else kwargs.get("client") if not MNEMONICS.mnemonic(name=mnemonic_name).is_valid(mnemonic=kwargs.get("mnemonic")): click.echo(click.style(f"Invalid {mnemonic_name} mnemonic {kwargs.get('mnemonic')!r}"), err=True) + try: + MNEMONICS.mnemonic(name=mnemonic_name).decode(mnemonic=kwargs.get("mnemonic")) + except Exception as exc: + import traceback + click.echo(f"{traceback.format_exc()}") sys.exit() - if kwargs.get("client") == BIP39Seed.name(): seed: ISeed = BIP39Seed( seed=BIP39Seed.from_mnemonic( diff --git a/hdwallet/mnemonics/slip39/__init__.py b/hdwallet/mnemonics/slip39/__init__.py new file mode 100644 index 00000000..a0eca5ee --- /dev/null +++ b/hdwallet/mnemonics/slip39/__init__.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2024, Meheret Tesfaye Batu +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +from typing import List + +from .mnemonic import ( + SLIP39Mnemonic +) + + +__all__: List[str] = [ + "SLIP39Mnemonic", +] diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py new file mode 100644 index 00000000..6adb329d --- /dev/null +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2024, Meheret Tesfaye Batu +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +import re +from typing import ( + Union, Dict, List, Optional, Tuple +) + +from ...entropies import ( + IEntropy, SLIP39Entropy, SLIP39_ENTROPY_STRENGTHS +) +from ...exceptions import ( + Error, EntropyError, MnemonicError, ChecksumError +) +from ..imnemonic import IMnemonic + +from shamir_mnemonic import split_ems, group_ems_mnemonics +from shamir_mnemonic.constants import MAX_SHARE_COUNT + +class SLIP39_MNEMONIC_WORDS: + + TWENTY: int = 20 + THIRTY_THREE: int = 33 + FIFTY_NINE: int = 59 + + +class SLIP39_MNEMONIC_LANGUAGES: + + ENGLISH: str = "english" + + + +def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tuple[int,int]]: + """Parse a SLIP-39 group specification; a name up to the first digit, ( or /, then a + threshold/count spec: + + Frens6, Frens 6, Frens(6) - A 3/6 group (default is 1/2 of group size, rounded up) + Frens2/6, Frens(2/6) - A 2/6 group + + Prevents 1/N groups (use 1/1, and duplicate the mnemonic to the N participants). + + All aspects of a group specification are optional; an empty spec yields a default group. + + """ + g_match = group_parser.RE.match( group_spec ) + if not g_match: + raise ValueError( f"Invalid group specification: {group_spec!r}" ) + name = g_match.group( 'name' ) or "" + if name: + name = name.strip() + size = g_match.group( 'size' ) + require = g_match.group( 'require' ) + if not size: + # eg. default or inverse required/size ratio iff require provided. Otherwise can't guess. + if size_default: + size = size_default + elif require: + print( f"Deducing size from require {require!r}" ) + require = int( require ) + size = int( require / group_parser.REQUIRED_RATIO + 0.5 ) + if size == 1 or require == 1: + size = require + else: + size = 1 # No spec, no require; default to group size of 1 + size = int( size ) + if not require: + # eg. 2/4, 3/5 for size producing require > 1; else, require = size (avoids 1/N groups) + require = int( size * group_parser.REQUIRED_RATIO + 0.5 ) + if size == 1 or require == 1: + require = size + require = int(require) + if size < 1 or require > size or ( require == 1 and size > 1 ): + raise ValueError( f"Impossible group specification from {group_spec!r}: {name,(require,size)!r}" ) + + return name,(require,size) + +group_parser.REQUIRED_RATIO = 1/2 +group_parser.RE = re.compile( # noqa E305 + r""" + ^ + \s* + (?P [^\d(/]+ )? + \s* + [(]? + \s* + (?: + (?P \d* ) + \s* + [/] + )? + \s* + (?: + (?P \d* ) + \s* + )? + [)]? + \s* + $ + """, re.VERBOSE ) + + +def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[str,int],Tuple[int,int]]]: + """ + Parse a SLIP-39 language dialect specification. + + Name threshold/groups [;: group1 thresh1/mnems1, g2(t2/n1) ... ] + ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + optional sep default comma-separated mnemonic + name a groups spec group thresholds (optional if no /) + + { + ("name",(threshold/groups)): { + "group1": (thresh1/mnems1), + "g2": (t2/n2), + } + } + + + """ + s_match = language_parser.RE.match(language) + if not s_match and language.strip(): + raise ValueError( f"Invalid SLIP-39 specification: {language!r}" ) + + groups_spec = s_match and s_match.group("groups") or ",,," + groups_list = groups_spec.split(",") + secret = s_match and s_match.group("secret") or "" + s_size_default = len(groups_list) if groups_list else None + s_name,(s_thresh,s_size) = group_parser(secret, size_default=s_size_default) + groups_list += [''] * (s_size - len(groups_list)) # default any missing group specs + + print( f"Parsing {language!r} SLIP-39 spec {s_name,(s_thresh,s_size)!r} w/ groups: {groups_list!r}" ) + g_names,g_sizes = [],[] + for group in groups_list: + # Default size inferred from Fibonacci sequence of mnemonics required by default + size_default = None if len(g_sizes) < 2 else min( + MAX_SHARE_COUNT, + 2 * ( g_sizes[-1][0] + g_sizes[-2][0] ) + ) + g_name,g_dims = group_parser(group, size_default=size_default) + if not g_name: + g_name = len(g_sizes) + g_names.append(g_name) + g_sizes.append(g_dims) + + print( f"Group specs: {g_sizes!r}" ) + return { (s_name.strip(),(s_thresh,s_size)): dict(zip(g_names,g_sizes)) } + +language_parser.REQUIRED_RATIO = 1/2 +language_parser.RE = re.compile( + r""" + ^ + \s* + (?: + (?P [^\[<{;:]* ) + \s* + [\[<{;:] + )? + \s* + (?P [^\]>}]* ) + [\]>}]? + \s* + $ + """, re.VERBOSE) + + +class SLIP39Mnemonic(IMnemonic): + """ + Implements the SLIP39 standard, allowing the creation of mnemonic phrases for + recovering deterministic keys. + + Here are available ``SLP39_MNEMONIC_WORDS``: + + +-----------------------+----------------------+ + | Name | Value | + +=======================+======================+ + | TWENTY | 20 | + +-----------------------+----------------------+ + | THIRTY_THREE | 33 | + +-----------------------+----------------------+ + | FIFTY_NINE | 59 | + +-----------------------+----------------------+ + + Here are available ``SLIP39_MNEMONIC_LANGUAGES``: + + +-----------------------+----------------------+ + | Name | Value | + +=======================+======================+ + | ENGLISH | english | + +-----------------------+----------------------+ + """ + + word_bit_length: int = 10 + words_list_number: int = 1024 + words_list: List[int] = [ + SLIP39_MNEMONIC_WORDS.TWENTY, + SLIP39_MNEMONIC_WORDS.THIRTY_THREE, + SLIP39_MNEMONIC_WORDS.FIFTY_NINE, + ] + words_to_entropy_strength: Dict[int, int] = { + SLIP39_MNEMONIC_WORDS.TWENTY: SLIP39_ENTROPY_STRENGTHS.ONE_HUNDRED_TWENTY_EIGHT, + SLIP39_MNEMONIC_WORDS.THIRTY_THREE: SLIP39_ENTROPY_STRENGTHS.TWO_HUNDRED_FIFTY_SIX, + SLIP39_MNEMONIC_WORDS.FIFTY_NINE: SLIP39_ENTROPY_STRENGTHS.FIVE_HUNDRED_TWELVE, + } + languages: List[str] = [ + ] + wordlist_path: Dict[str, str] = { + } + + @classmethod + def name(cls) -> str: + """ + Get the name of the mnemonic class. + + :return: The name of the entropy class. + :rtype: str + """ + return "SLIP39" + + @classmethod + def from_words(cls, words: int, language: str) -> str: + """Generates a mnemonic phrase from a specified number of words. + + This method generates a mnemonic phrase based on the specified number of words and language. + For SLIP-39, the language word dictionary is always the same (english) so is ignored (simply + used as a label for the generated SLIP-39), but the rest of the language string specifies + the "dialect" (threshold of groups required/generated, and the threshold of mnemonics + required/generated in each group). + + The default is: + + - A threshold is 1/2 the specified number of groups/mnemonics (rounded up), and + - 4 groups of 1, 1, 4 and 6 mnemonics + + All of these language specifications produce the same 2/4 group SLIP-39 encoding: + + "" + "Johnson" + "2: 1/1, 1/1, 2/4, 3/6" + "Johnson 2/4: Home 1/1, Office 1/1, Fam 2/4, Frens 3/6" + + :param words: The number of words for the mnemonic phrase. + :type words: int + :param language: The language for the mnemonic phrase. + :type language: str + + :return: The generated mnemonic phrase. + :rtype: str + + """ + if words not in cls.words_list: + raise MnemonicError("Invalid mnemonic words number", expected=cls.words_list, got=words) + + return cls.from_entropy( + entropy=SLIP39Entropy.generate(cls.words_to_entropy_strength[words]), language=language + ) + + @classmethod + def from_entropy(cls, entropy: Union[str, bytes, IEntropy], language: str, **kwargs) -> str: + """ + Generates from entropy data. Any entropy of the correct size can be encoded as SLIP-39. + + :param entropy: The entropy data used to generate the mnemonic phrase. + :type entropy: Union[str, bytes, IEntropy] + :param language: The language for the mnemonic phrase. + :type language: str + + :return: The generated mnemonic phrase. + :rtype: str + """ + if isinstance(entropy, str) or isinstance(entropy, bytes): + return cls.encode(entropy=entropy, language=language) + elif isinstance(entropy, IEntropy) and entropy.strength() in SLIP39Entropy.strengths: + return cls.encode(entropy=entropy.entropy(), language=language) + raise EntropyError( + "Invalid entropy instance", expected=[str, bytes,]+list(ENTROPIES.dictionary.values()), got=type(entropy) + ) + + @classmethod + def encode(cls, entropy: Union[str, bytes], language: str) -> str: + """ + Encodes entropy into a mnemonic phrase. + + This method converts a given entropy value into a mnemonic phrase according to the specified language. + + :param entropy: The entropy to encode into a mnemonic phrase. + :type entropy: Union[str, bytes] + :param language: The language for the mnemonic phrase. + :type language: str + + :return: The encoded mnemonic phrase. + :rtype: str + """ + + entropy: bytes = get_bytes(entropy, unhexlify=True) + if not BIP39Entropy.is_valid_bytes_strength(len(entropy)): + raise EntropyError( + "Wrong entropy strength", expected=BIP39Entropy.strengths, got=(len(entropy) * 8) + ) + + entropy_binary_string: str = bytes_to_binary_string(get_bytes(entropy), len(entropy) * 8) + entropy_hash_binary_string: str = bytes_to_binary_string(sha256(entropy), 32 * 8) + mnemonic_bin: str = entropy_binary_string + entropy_hash_binary_string[:len(entropy) // 4] + + mnemonic: List[str] = [] + words_list: List[str] = cls.normalize(cls.get_words_list_by_language(language=language)) + if len(words_list) != cls.words_list_number: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) + ) + + for index in range(len(mnemonic_bin) // cls.word_bit_length): + word_bin: str = mnemonic_bin[index * cls.word_bit_length:(index + 1) * cls.word_bit_length] + word_index: int = binary_string_to_integer(word_bin) + mnemonic.append(words_list[word_index]) + + return " ".join(cls.normalize(mnemonic)) + + @classmethod + def decode( + cls, mnemonic: str, checksum: bool = False, words_list: Optional[List[str]] = None, words_list_with_index: Optional[dict] = None + ) -> str: + """ + Decodes a mnemonic phrase into its corresponding entropy. + + This method converts a given mnemonic phrase back into its original entropy value. + It also verifies the checksum to ensure the mnemonic is valid. + + :param mnemonic: The mnemonic phrase to decode. + :type mnemonic: str + :param checksum: Whether to include the checksum in the returned entropy. + :type checksum: bool + :param words_list: Optional list of words used to decode the mnemonic. If not provided, the method will use the default word list for the language detected. + :type words_list: Optional[List[str]] + :param words_list_with_index: Optional dictionary mapping words to their indices for decoding. If not provided, the method will use the default mapping. + :type words_list_with_index: Optional[dict] + + :return: The decoded entropy as a string. + :rtype: str + """ + + words: list = cls.normalize(mnemonic) + if len(words) not in cls.words_list: + raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) + + if not words_list or not words_list_with_index: + words_list, language = cls.find_language(mnemonic=words) + if len(words_list) != cls.words_list_number: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) + ) + words_list_with_index: dict = { + words_list[i]: i for i in range(len(words_list)) + } + + if len(words_list) != cls.words_list_number: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) + ) + + mnemonic_bin: str = "".join(map( + lambda word: integer_to_binary_string( + words_list_with_index[word], cls.word_bit_length + ), words + )) + + mnemonic_bit_length: int = len(mnemonic_bin) + checksum_length: int = mnemonic_bit_length // 33 + checksum_bin: str = mnemonic_bin[-checksum_length:] + entropy: bytes = binary_string_to_bytes( + mnemonic_bin[:-checksum_length], checksum_length * 8 + ) + entropy_hash_bin: str = bytes_to_binary_string( + sha256(entropy), 32 * 8 + ) + checksum_bin_got: str = entropy_hash_bin[:checksum_length] + if checksum_bin != checksum_bin_got: + raise ChecksumError( + "Invalid checksum", expected=checksum_bin, got=checksum_bin_got + ) + + if checksum: + pad_bit_len: int = ( + mnemonic_bit_length + if mnemonic_bit_length % 8 == 0 else + mnemonic_bit_length + (8 - mnemonic_bit_length % 8) + ) + return bytes_to_string( + binary_string_to_bytes(mnemonic_bin, pad_bit_len // 4) + ) + return bytes_to_string(entropy) + + @classmethod + def is_valid( + cls, + mnemonic: Union[str, List[str]], + words_list: Optional[List[str]] = None, + words_list_with_index: Optional[dict] = None + ) -> bool: + """ + Validates a mnemonic phrase. + + This method checks whether the provided mnemonic phrase is valid by attempting to decode it. + If the decoding is successful without raising any errors, the mnemonic is considered valid. + + :param mnemonic: The mnemonic phrase to validate. It can be a string or a list of words. + :type mnemonic: Union[str, List[str]] + :param words_list: Optional list of words to be used for validation. If not provided, the method will use the default word list. + :type words_list: Optional[List[str]] + :param words_list_with_index: Optional dictionary mapping words to their indices for validation. If not provided, the method will use the default mapping. + :type words_list_with_index: Optional[dict] + + :return: True if the mnemonic phrase is valid, False otherwise. + :rtype: bool + """ + + try: + cls.decode( + mnemonic=mnemonic, words_list=words_list, words_list_with_index=words_list_with_index + ) + return True + except (Error, KeyError): + return False diff --git a/setup.py b/setup.py index b34cdaf7..3feb6e34 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,8 @@ def get_requirements(name: str) -> List[str]: extras_require=dict( cli=get_requirements(name="requirements/cli"), docs=get_requirements(name="requirements/docs"), - tests=get_requirements(name="requirements/tests") + tests=get_requirements(name="requirements/tests"), + slip39=get_requirements(name="requirements/slip39") ), classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py new file mode 100644 index 00000000..4738a9d2 --- /dev/null +++ b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py @@ -0,0 +1,50 @@ +from hdwallet.mnemonics.slip39.mnemonic import ( + SLIP39Mnemonic, language_parser, group_parser +) + + +def test_slip39_language(): + spec = language_parser("") + assert spec == { + ("",(2,4)): { + 0: (1,1), + 1: (1,1), + 2: (2,4), + 3: (3,6), + }, + } + + # default secret spec, and a single group w/ default size + group = group_parser(" 3 / ", size_default=None) + assert group == ("",(3,6)) + spec = language_parser(" 3 / ") + assert spec == { + ("",(1,1)): { + 0: (3,6), + }, + } + + # A secret w/ threshold 3 required, of the default 4 groups of fibonacci required mnemonics + spec = language_parser(" 3 / [ ] ") + assert spec == { + ("",(3,4)): { + 0: (1,1), + 1: (1,1), + 2: (2,4), + 3: (3,6), + }, + } + + spec = language_parser("Satoshi Nakamoto 7 [ 2/3 ] ") + assert spec == { + ("Satoshi Nakamoto",(4,7)): { + 0: (2,3), + 1: (1,1), + 2: (3,6), + 3: (4,8), + 4: (7,14), + 5: (8,16), + 6: (8,16), + }, + } + From f7c831eb640b131890a62e09c367c92c1850f19e Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Tue, 19 Aug 2025 15:32:56 -0600 Subject: [PATCH 09/38] Working implementation of SLIP39 Mnemonics --- Makefile | 9 +- hdwallet/mnemonics/imnemonic.py | 6 +- hdwallet/mnemonics/slip39/mnemonic.py | 301 +++++++++++------- hdwallet/seeds/slip39.py | 6 +- hdwallet/utils.py | 5 +- requirements/slip39.txt | 2 + .../mnemonics/test_mnemonics_slip39.py | 55 ++-- 7 files changed, 233 insertions(+), 151 deletions(-) create mode 100644 requirements/slip39.txt diff --git a/Makefile b/Makefile index b563afa2..1a23bf4d 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,10 @@ export PYTHON ?= $(shell python3 --version >/dev/null 2>&1 && echo python3 || e # Ensure $(PYTHON), $(VENV) are re-evaluated at time of expansion, when target 'python' and 'poetry' are known to be available PYTHON_V = $(shell $(PYTHON) -c "import sys; print('-'.join((('venv' if sys.prefix != sys.base_prefix else next(iter(filter(None,sys.base_prefix.split('/'))))),sys.platform,sys.implementation.cache_tag)))" 2>/dev/null ) +export PYTEST ?= $(PYTHON) -m pytest +export PYTEST_OPTS ?= # -vv --capture=no --mypy + + VERSION = $(shell $(PYTHON) -c "exec(open('hdwallet/info.py').read()); print(__version__[1:])" ) WHEEL = dist/hdwallet-$(VERSION)-py3-none-any.whl VENV = $(CURDIR)-$(VERSION)-$(PYTHON_V) @@ -46,7 +50,10 @@ install-%: FORCE unit-%: - $(PYTHON) -m pytest -k $* + $(PYTEST) $(PYTEST_OPTS) -k $* + +test: + $(PYTEST) $(PYTEST_OPTS) tests # # Nix and VirtualEnv build, install and activate diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 7c5b0991..596a1f35 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -228,6 +228,10 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: mnemonic strings, or raw hex-encoded entropy, if they use the IMnemonic.normalize base method in their derived 'decode' and 'is_valid' implementations. + This makes sense for most Mnemonics, which produce an repeatable encoding for the same entropy; + Mnemonics that produce different encodings will need alternative implementations. They should + handle raw entropy directly. + :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. :type mnemonic: Union[str, List[str]] @@ -238,6 +242,6 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: if isinstance(mnemonic, str): if all(c in string.hexdigits for c in mnemonic.strip()): mnemonic: str = cls.from_entropy(mnemonic, language="english") - mnemonic: list = mnemonic.split() + mnemonic: list = mnemonic.strip().split() return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index 6adb329d..c9980635 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -6,7 +6,7 @@ import re from typing import ( - Union, Dict, List, Optional, Tuple + Union, Dict, Iterable, List, Optional, Tuple ) from ...entropies import ( @@ -15,10 +15,16 @@ from ...exceptions import ( Error, EntropyError, MnemonicError, ChecksumError ) +from ...utils import ( + get_bytes, + bytes_to_string, +) from ..imnemonic import IMnemonic -from shamir_mnemonic import split_ems, group_ems_mnemonics +from shamir_mnemonic import generate_mnemonics from shamir_mnemonic.constants import MAX_SHARE_COUNT +from shamir_mnemonic.recovery import RecoveryState, Share + class SLIP39_MNEMONIC_WORDS: @@ -58,7 +64,6 @@ def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tu if size_default: size = size_default elif require: - print( f"Deducing size from require {require!r}" ) require = int( require ) size = int( require / group_parser.REQUIRED_RATIO + 0.5 ) if size == 1 or require == 1: @@ -73,7 +78,7 @@ def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tu require = size require = int(require) if size < 1 or require > size or ( require == 1 and size > 1 ): - raise ValueError( f"Impossible group specification from {group_spec!r}: {name,(require,size)!r}" ) + raise ValueError( f"Impossible group specification from {group_spec!r} w/ default size {size_default!r}: {name,(require,size)!r}" ) return name,(require,size) @@ -104,15 +109,28 @@ def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tu def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[str,int],Tuple[int,int]]]: """ - Parse a SLIP-39 language dialect specification. + Parse a SLIP-39 language dialect specification. + + optional sep default comma-separated mnemonic + name a secret spec group thresholds (optional if no /) + --------------------- --- -------------------------------------- - + + "Name threshold/groups" + ^^^^^^^^^^^^^^^^^^^^^ + - no separator or commas, must be a secret encoding specification + + "group1 thresh1/mnems1, g2(t2/n1) ..." + ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + - commas; can't be a secret encoding spec, so must be group specs Name threshold/groups [;: group1 thresh1/mnems1, g2(t2/n1) ... ] ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ - optional sep default comma-separated mnemonic - name a groups spec group thresholds (optional if no /) + - separator; must be both a secret encoding spec, and 0 or more group specs + - spec(s) may be partial, eg. "3" (size) or "3/" (threshold + - sensible defaults are deduced for missing specs, if possible { - ("name",(threshold/groups)): { + ("Name",(threshold/groups)): { "group1": (thresh1/mnems1), "g2": (t2/n2), } @@ -124,14 +142,14 @@ def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[ if not s_match and language.strip(): raise ValueError( f"Invalid SLIP-39 specification: {language!r}" ) - groups_spec = s_match and s_match.group("groups") or ",,," - groups_list = groups_spec.split(",") + + groups = s_match and s_match.group("groups") or "" + groups_list = groups.strip().split(",") secret = s_match and s_match.group("secret") or "" s_size_default = len(groups_list) if groups_list else None s_name,(s_thresh,s_size) = group_parser(secret, size_default=s_size_default) groups_list += [''] * (s_size - len(groups_list)) # default any missing group specs - print( f"Parsing {language!r} SLIP-39 spec {s_name,(s_thresh,s_size)!r} w/ groups: {groups_list!r}" ) g_names,g_sizes = [],[] for group in groups_list: # Default size inferred from Fibonacci sequence of mnemonics required by default @@ -145,7 +163,6 @@ def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[ g_names.append(g_name) g_sizes.append(g_dims) - print( f"Group specs: {g_sizes!r}" ) return { (s_name.strip(),(s_thresh,s_size)): dict(zip(g_names,g_sizes)) } language_parser.REQUIRED_RATIO = 1/2 @@ -153,15 +170,22 @@ def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[ r""" ^ \s* - (?: - (?P [^\[<{;:]* ) - \s* - [\[<{;:] + (?P # Any single name and/or spec w/ no separator or comma + ( + [\w\s]* \d* \s* /? \s* \d* + ) )? - \s* - (?P [^\]>}]* ) - [\]>}]? - \s* + \s* [\[<{;:]? \s* # An optional separator or bracket may appear before group spec(s) + (?P # The group spec(s), comma separated + ( + [\w\s]* \d* \s* /? \s* \d* + ) + ( + \s* , + [\w\s]* \d* \s* /? \s* \d* + )* + )? + \s* [\]>}]? \s* # And optionally a trailing inverse bracket $ """, re.VERBOSE) @@ -209,6 +233,12 @@ class SLIP39Mnemonic(IMnemonic): wordlist_path: Dict[str, str] = { } + + def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: + super().__init__(mnemonic, **kwargs) + # We know that normalize has already validated _mnemonic's length + self._words, = filter(lambda l: len(self._mnemonic) % l == 0, self.words_list) + @classmethod def name(cls) -> str: """ @@ -279,147 +309,172 @@ def from_entropy(cls, entropy: Union[str, bytes, IEntropy], language: str, **kwa ) @classmethod - def encode(cls, entropy: Union[str, bytes], language: str) -> str: + def encode( + cls, + entropy: Union[str, bytes], + language: str, + passphrase: str = "", + extendable: bool = True, + iteration_exponent: int = 1, + tabulate: bool = False, + ) -> str: """ Encodes entropy into a mnemonic phrase. - This method converts a given entropy value into a mnemonic phrase according to the specified language. + This method converts a given entropy value into a mnemonic phrase according to the specified + language. + + SLIP-39 mnemonics include a password. This is normally empty, and is not well supported + even on Trezor devices. It is better to use SLIP-39 to encode a BIP-39 Mnemonic's entropy + and then (after recovering it from SLIP-39), use a BIP-39 passphrase (which is well + supported across all devices), or use the "Passphrase Wallet" feature of your hardware wallet + device. :param entropy: The entropy to encode into a mnemonic phrase. :type entropy: Union[str, bytes] :param language: The language for the mnemonic phrase. :type language: str + :param passphrase: The SLIP-39 passphrase (default: "") + :type passphrase: str :return: The encoded mnemonic phrase. :rtype: str - """ + """ entropy: bytes = get_bytes(entropy, unhexlify=True) - if not BIP39Entropy.is_valid_bytes_strength(len(entropy)): + if not SLIP39Entropy.is_valid_bytes_strength(len(entropy)): raise EntropyError( - "Wrong entropy strength", expected=BIP39Entropy.strengths, got=(len(entropy) * 8) + "Wrong entropy strength", expected=SLIP39Entropy.strengths, got=(len(entropy) * 8) ) - entropy_binary_string: str = bytes_to_binary_string(get_bytes(entropy), len(entropy) * 8) - entropy_hash_binary_string: str = bytes_to_binary_string(sha256(entropy), 32 * 8) - mnemonic_bin: str = entropy_binary_string + entropy_hash_binary_string[:len(entropy) // 4] - - mnemonic: List[str] = [] - words_list: List[str] = cls.normalize(cls.get_words_list_by_language(language=language)) - if len(words_list) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) - ) + ((s_name,(s_thresh,s_size)),groups), = language_parser(language).items() + assert s_size == len(groups) + group_mnemonics: Sequence[Sequence[str]] = generate_mnemonics( + group_threshold = s_thresh, + groups = groups.values(), + master_secret = entropy, + passphrase = passphrase.encode('UTF-8'), + extendable = extendable, + iteration_exponent = iteration_exponent, + ) - for index in range(len(mnemonic_bin) // cls.word_bit_length): - word_bin: str = mnemonic_bin[index * cls.word_bit_length:(index + 1) * cls.word_bit_length] - word_index: int = binary_string_to_integer(word_bin) - mnemonic.append(words_list[word_index]) + return "\n".join(sum(group_mnemonics, [])) - return " ".join(cls.normalize(mnemonic)) @classmethod def decode( - cls, mnemonic: str, checksum: bool = False, words_list: Optional[List[str]] = None, words_list_with_index: Optional[dict] = None + cls, mnemonic: str, passphrase: str = "", ) -> str: """ Decodes a mnemonic phrase into its corresponding entropy. - This method converts a given mnemonic phrase back into its original entropy value. - It also verifies the checksum to ensure the mnemonic is valid. + This method converts a given mnemonic phrase back into its original entropy value. It + verifies several internal hashes to ensure the mnemonic and decoding is valid. However, the + passphrase has no verification; all derived entropies are considered equivalently valid (you + can use several passphrases to recover multiple, distinct sets of entropy.) So, it is + solely your responsibility to remember your correct passphrase(s). :param mnemonic: The mnemonic phrase to decode. :type mnemonic: str - :param checksum: Whether to include the checksum in the returned entropy. - :type checksum: bool - :param words_list: Optional list of words used to decode the mnemonic. If not provided, the method will use the default word list for the language detected. - :type words_list: Optional[List[str]] - :param words_list_with_index: Optional dictionary mapping words to their indices for decoding. If not provided, the method will use the default mapping. - :type words_list_with_index: Optional[dict] + :param passphrase: The SLIP-39 passphrase (default: "") + :type passphrase: str :return: The decoded entropy as a string. :rtype: str - """ - words: list = cls.normalize(mnemonic) - if len(words) not in cls.words_list: - raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - - if not words_list or not words_list_with_index: - words_list, language = cls.find_language(mnemonic=words) - if len(words_list) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) - ) - words_list_with_index: dict = { - words_list[i]: i for i in range(len(words_list)) - } - - if len(words_list) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) + """ + mnemonic_list: List[str] = cls.normalize(mnemonic) + mnemonic_words, = filter(lambda words: len(mnemonic_list) % words == 0, cls.words_list) + mnemonic_chunks: Iterable[List[str]] = zip(*[iter(mnemonic_list)] * mnemonic_words) + mnemonic: Iterable[str] = map(" ".join, mnemonic_chunks) + recovery = RecoveryState() + try: + while not recovery.is_complete(): + recovery.add_share(Share.from_mnemonic(next(mnemonic))) + return bytes_to_string(recovery.recover(passphrase.encode('UTF-8'))) + except Exception as exc: + raise MnemonicError(f"Failed to recover SLIP-39 Mnemonics", detail=exc) + + + NORMALIZE = re.compile( + r""" + ^ + ( + [\w\d\s]* [^\w\d\s] # Group 1 { + )? + ( + [\w\s]* # word word ... ) + $ + """, re.VERBOSE ) - mnemonic_bin: str = "".join(map( - lambda word: integer_to_binary_string( - words_list_with_index[word], cls.word_bit_length - ), words - )) - - mnemonic_bit_length: int = len(mnemonic_bin) - checksum_length: int = mnemonic_bit_length // 33 - checksum_bin: str = mnemonic_bin[-checksum_length:] - entropy: bytes = binary_string_to_bytes( - mnemonic_bin[:-checksum_length], checksum_length * 8 - ) - entropy_hash_bin: str = bytes_to_binary_string( - sha256(entropy), 32 * 8 - ) - checksum_bin_got: str = entropy_hash_bin[:checksum_length] - if checksum_bin != checksum_bin_got: - raise ChecksumError( - "Invalid checksum", expected=checksum_bin, got=checksum_bin_got - ) + @classmethod + def find_language( + cls, mnemonic: List[str], wordlist_path: Optional[Dict[str, str]] = None + ) -> Union[str, Tuple[List[str], str]]: + return [],"" - if checksum: - pad_bit_len: int = ( - mnemonic_bit_length - if mnemonic_bit_length % 8 == 0 else - mnemonic_bit_length + (8 - mnemonic_bit_length % 8) - ) - return bytes_to_string( - binary_string_to_bytes(mnemonic_bin, pad_bit_len // 4) - ) - return bytes_to_string(entropy) @classmethod - def is_valid( - cls, - mnemonic: Union[str, List[str]], - words_list: Optional[List[str]] = None, - words_list_with_index: Optional[dict] = None - ) -> bool: - """ - Validates a mnemonic phrase. + def get_words_list_by_language( + cls, language: str, wordlist_path: Optional[Dict[str, str]] = None + ) -> List[str]: + return [] - This method checks whether the provided mnemonic phrase is valid by attempting to decode it. - If the decoding is successful without raising any errors, the mnemonic is considered valid. - - :param mnemonic: The mnemonic phrase to validate. It can be a string or a list of words. - :type mnemonic: Union[str, List[str]] - :param words_list: Optional list of words to be used for validation. If not provided, the method will use the default word list. - :type words_list: Optional[List[str]] - :param words_list_with_index: Optional dictionary mapping words to their indices for validation. If not provided, the method will use the default mapping. - :type words_list_with_index: Optional[dict] + + @classmethod + def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: + """Filter the supplied lines of mnemonics, rejecting groups of mnemonics not evenly divisible by + one of the recognized SLIP-39 mnemonic lengths. + + Also accepts a single hex raw entropy value (converting it into a simple single-mnemonic + (1/1 groups) SLIP-39 encoding). + + Filter out any prefixes consisting of word/space symbols followed by a single non-word/space + symbol, before any number of Mnemonic word/space symbols: + + Group 1 { word word ... + Group 2 ╭ word word ... + ╰ word word ... + Group 3 ┌ word word ... + ├ word word ... + └ word word ... + ^^^^^^^^ ^ ^^^^^^^^^^... + | | | + word/digit/space* | word/space* + | + single non-word/digit/space - :return: True if the mnemonic phrase is valid, False otherwise. - :rtype: bool """ - - try: - cls.decode( - mnemonic=mnemonic, words_list=words_list, words_list_with_index=words_list_with_index + errors = [] + if isinstance( mnemonic, str ): + mnemonic_list: List[str] = [] + mnemonic_lines = filter(None, mnemonic.strip().split("\n")) + + for line_no,m in enumerate( map( cls.NORMALIZE.match, mnemonic_lines)): + pref,mnem = m.groups() + if not mnem: # Blank lines or lines without Mnemonic skipped + continue + mnem = super().normalize(mnem) + if len(mnem) in cls.words_list: + mnemonic_list.extend(mnem) + else: + errors.append( f"@L{line_no}; odd {len(mnem)}-word mnemonic ignored" ) + else: + mnemonic_list: List[str] = mnemonic + + word_lengths = list(filter(lambda l: len(mnemonic_list) % l == 0, cls.words_list)) + if not word_lengths: + errors.append( f"Mnemonics not a multiple of valid length, or a single hex entropy value" ) + if errors: + raise MnemonicError( + f"Invalid SLIP39 Mnemonics", + expected=f"multiple of {', '.join(map(str, cls.words_list))}", + got=f"{len(mnemonic_list)} total words", + detail="; ".join(errors), ) - return True - except (Error, KeyError): - return False + + return mnemonic_list + + + diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index 6cfe9c0e..e0b91d27 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -10,8 +10,10 @@ import unicodedata +from ..exceptions import EntropyError from ..mnemonics import IMnemonic from ..mnemonics.bip39 import BIP39Mnemonic +from ..mnemonics.slip39 import SLIP39Mnemonic from .iseed import ISeed @@ -67,11 +69,9 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str if not isinstance(mnemonic, IMnemonic): # Not an IMnemonic; must be a str. Try the supported mnemonic encodings we'll allow for # SLIP39 seeds, converting the mnemonic phrase to an IMnemonic if recognized. - # - # TODO: Eventually add SLIP-39. allowed_entropy = [ BIP39Mnemonic, - # SLIP39Mnemonic, ... + SLIP39Mnemonic, # ... ] for M in allowed_entropy: diff --git a/hdwallet/utils.py b/hdwallet/utils.py index e82f2bcf..252a22c0 100644 --- a/hdwallet/utils.py +++ b/hdwallet/utils.py @@ -523,7 +523,8 @@ def bytes_to_string(data: AnyStr, unhexlify: Optional[bool] = None) -> str: If the default unhexlify == None is provided, will attempt to auto-detect non-empty hex strings, and thus reject hex strings of accidentally odd length instead of accepting them (surprisingly - and almost certainly incorrectly!) as UTF-8 encoded binary data. + and almost certainly incorrectly!) as UTF-8 encoded binary data (get_bytes is resilient to + surrounding whitespace, so we must be, too). :param data: The bytes or string data to convert to hexadecimal string. :type data: Union[bytes, str] @@ -538,7 +539,7 @@ def bytes_to_string(data: AnyStr, unhexlify: Optional[bool] = None) -> str: if not data: return '' if unhexlify is None: - unhexlify = isinstance(data, str) and all(c in string.hexdigits for c in data) + unhexlify = isinstance(data, str) and all(c in string.hexdigits for c in data.strip()) binary = get_bytes(data, unhexlify=unhexlify) return binary.hex() diff --git a/requirements/slip39.txt b/requirements/slip39.txt new file mode 100644 index 00000000..c6bd2e24 --- /dev/null +++ b/requirements/slip39.txt @@ -0,0 +1,2 @@ +#shamir-mnemonic-slip39>=0.4.2,<0.5 +shamir-mnemonic>=0.3,<0.4 diff --git a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py index 4738a9d2..8ee27c8e 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py @@ -4,37 +4,43 @@ def test_slip39_language(): - spec = language_parser("") + + # Any name; no spec --> simplest 1/1 group encoding yielding single mnemonic + spec = language_parser("english") assert spec == { - ("",(2,4)): { + ("english",(1,1)): { 0: (1,1), - 1: (1,1), - 2: (2,4), - 3: (3,6), }, } - # default secret spec, and a single group w/ default size - group = group_parser(" 3 / ", size_default=None) - assert group == ("",(3,6)) - spec = language_parser(" 3 / ") + # No name or secret spec, and a single group w/ default size based on group threshold + group = group_parser("Name 3 / ", size_default=None) + assert group == ("Name",(3,6)) + spec = language_parser(": Name 3 / ") assert spec == { ("",(1,1)): { - 0: (3,6), + "Name": (3,6), }, } - + # A secret w/ threshold 3 required, of the default 4 groups of fibonacci required mnemonics - spec = language_parser(" 3 / [ ] ") - assert spec == { - ("",(3,4)): { - 0: (1,1), - 1: (1,1), - 2: (2,4), - 3: (3,6), - }, - } - + for language in [ + " 3 / 4 ", + " 3 / 4 [ ] ", + " 3 / : ,,, ", + " 3 / : 1/, 1, 4, 3/ ", + ]: + spec = language_parser(language) + assert spec == { + ("",(3,4)): { + 0: (1,1), + 1: (1,1), + 2: (2,4), + 3: (3,6), + }, + }, f"Language {language} yielded incorrect encoding: {spec!r}" + + # If some group specs are provided, the rest are deduced in a fibonacci-ish sequence spec = language_parser("Satoshi Nakamoto 7 [ 2/3 ] ") assert spec == { ("Satoshi Nakamoto",(4,7)): { @@ -48,3 +54,10 @@ def test_slip39_language(): }, } +def test_slip39_mnemonics(): + entropy = "ff"*(256//8) + mnemonics = SLIP39Mnemonic.encode(entropy=entropy, language="") + mnemonics_list = SLIP39Mnemonic.normalize(mnemonics) + recovered = SLIP39Mnemonic.decode(mnemonics_list) + assert recovered == entropy + From a8a5e5e5d5631768004cd46b0c449dbf06b83de8 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Wed, 20 Aug 2025 07:41:54 -0600 Subject: [PATCH 10/38] Improve SLIP39 Mnemonic support, and general wordlist handling --- MANIFEST.in | 1 + hdwallet/cli/generate/seed.py | 7 +- hdwallet/mnemonics/__init__.py | 5 +- hdwallet/mnemonics/imnemonic.py | 21 +- hdwallet/mnemonics/slip39/mnemonic.py | 31 +- .../mnemonics/slip39/wordlist/english.txt | 1024 +++++++++++++++++ tests/cli/test_cli_seed.py | 3 +- tests/data/json/seeds.json | 6 +- tests/data/raw/languages.txt | 3 + .../mnemonics/test_mnemonics_slip39.py | 11 +- tests/hdwallet/seeds/test_seeds_slip39.py | 10 +- 11 files changed, 1081 insertions(+), 41 deletions(-) create mode 100644 hdwallet/mnemonics/slip39/wordlist/english.txt diff --git a/MANIFEST.in b/MANIFEST.in index 832de8e3..6ce6ad90 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,6 +9,7 @@ include requirements/dev.txt recursive-include hdwallet/mnemonics/algorand/wordlist *.txt recursive-include hdwallet/mnemonics/bip39/wordlist *.txt +recursive-include hdwallet/mnemonics/slip39/wordlist *.txt recursive-include hdwallet/mnemonics/electrum/v1/wordlist *.txt recursive-include hdwallet/mnemonics/electrum/v2/wordlist *.txt recursive-include hdwallet/mnemonics/monero/wordlist *.txt diff --git a/hdwallet/cli/generate/seed.py b/hdwallet/cli/generate/seed.py index a144c9cb..d00d7cdb 100644 --- a/hdwallet/cli/generate/seed.py +++ b/hdwallet/cli/generate/seed.py @@ -32,15 +32,10 @@ def generate_seed(**kwargs) -> None: ): click.echo(click.style(f"Invalid Electrum-V2 mnemonic"), err=True) sys.exit() - else: + elif kwargs.get("client") != SLIP39Seed.name(): # SLIP39 supports any 128-, 256- or 512-bit Mnemonic mnemonic_name: str = "BIP39" if kwargs.get("client") == CardanoSeed.name() else kwargs.get("client") if not MNEMONICS.mnemonic(name=mnemonic_name).is_valid(mnemonic=kwargs.get("mnemonic")): click.echo(click.style(f"Invalid {mnemonic_name} mnemonic {kwargs.get('mnemonic')!r}"), err=True) - try: - MNEMONICS.mnemonic(name=mnemonic_name).decode(mnemonic=kwargs.get("mnemonic")) - except Exception as exc: - import traceback - click.echo(f"{traceback.format_exc()}") sys.exit() if kwargs.get("client") == BIP39Seed.name(): diff --git a/hdwallet/mnemonics/__init__.py b/hdwallet/mnemonics/__init__.py index 01a32751..938e9643 100644 --- a/hdwallet/mnemonics/__init__.py +++ b/hdwallet/mnemonics/__init__.py @@ -15,6 +15,9 @@ from .bip39 import ( BIP39Mnemonic, BIP39_MNEMONIC_WORDS, BIP39_MNEMONIC_LANGUAGES ) +from .slip39 import ( + SLIP39Mnemonic +) from .electrum import ( ElectrumV1Mnemonic, ELECTRUM_V1_MNEMONIC_WORDS, ELECTRUM_V1_MNEMONIC_LANGUAGES, ElectrumV2Mnemonic, ELECTRUM_V2_MNEMONIC_WORDS, ELECTRUM_V2_MNEMONIC_LANGUAGES, ELECTRUM_V2_MNEMONIC_TYPES @@ -55,7 +58,7 @@ class MNEMONICS: ElectrumV1Mnemonic.name(): ElectrumV1Mnemonic, ElectrumV2Mnemonic.name(): ElectrumV2Mnemonic, MoneroMnemonic.name(): MoneroMnemonic, - "SLIP39": BIP39Mnemonic, + SLIP39Mnemonic.name(): SLIP39Mnemonic, } @classmethod diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 596a1f35..8bc46fcc 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -115,8 +115,10 @@ def decode(cls, mnemonic: Union[str, List[str]], **kwargs) -> str: def get_words_list_by_language( cls, language: str, wordlist_path: Optional[Dict[str, str]] = None ) -> List[str]: - """ - Retrieves the word list for the specified language. + """Retrieves the standardized (normal form KD, lower-cased) word list for the specified language. + + We do not want to use 'normalize' to do this, because normalization of Mnemonics may have + additional functionality beyond just ensuring symbol and case standardization. :param language: The language for which to get the word list. :type language: str @@ -125,12 +127,15 @@ def get_words_list_by_language( :return: A list of words for the specified language. :rtype: List[str] + """ wordlist_path = cls.wordlist_path if wordlist_path is None else wordlist_path with open(os.path.join(os.path.dirname(__file__), wordlist_path[language]), "r", encoding="utf-8") as fin: words_list: List[str] = [ - word.strip() for word in fin.readlines() if word.strip() != "" and not word.startswith("#") + unicodedata.normalize("NFKD", word.lower()) + for word in map(str.strip, fin.readlines()) + if word and not word.startswith("#") ] return words_list @@ -152,10 +157,8 @@ def find_language( for language in cls.languages: try: - words_list: list = cls.normalize( - cls.get_words_list_by_language( - language=language, wordlist_path=wordlist_path - ) + words_list: List[str] = cls.get_words_list_by_language( + language=language, wordlist_path=wordlist_path ) words_list_with_index: dict = { words_list[i]: i for i in range(len(words_list)) @@ -240,8 +243,8 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: """ if isinstance(mnemonic, str): - if all(c in string.hexdigits for c in mnemonic.strip()): + if ( len(mnemonic.strip()) * 4 in cls.words_to_entropy_strength.values() + and all(c in string.hexdigits for c in mnemonic.strip())): mnemonic: str = cls.from_entropy(mnemonic, language="english") mnemonic: list = mnemonic.strip().split() return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) - diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index c9980635..95ca2d5d 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -229,8 +229,10 @@ class SLIP39Mnemonic(IMnemonic): SLIP39_MNEMONIC_WORDS.FIFTY_NINE: SLIP39_ENTROPY_STRENGTHS.FIVE_HUNDRED_TWELVE, } languages: List[str] = [ + SLIP39_MNEMONIC_LANGUAGES.ENGLISH ] wordlist_path: Dict[str, str] = { + SLIP39_MNEMONIC_LANGUAGES.ENGLISH: "slip39/wordlist/english.txt", } @@ -249,6 +251,22 @@ def name(cls) -> str: """ return "SLIP39" + def mnemonic(self) -> str: + """ + Get the mnemonic as a single string. + + SLIP-39 Mnemonics usually have multiple lines. Iterates the _mnemonic words list by the + computed self.words(), joining each length of words by spaces to for a line, and then joins + by newlines. + + :return: The mnemonic as a single string joined by spaces and newlines. + :rtype: str + + """ + mnemonic_chunks: Iterable[List[str]] = zip(*[iter(self._mnemonic)] * self._words) + mnemonic: Iterable[str] = map(" ".join, mnemonic_chunks) + return "\n".join(mnemonic) + @classmethod def from_words(cls, words: int, language: str) -> str: """Generates a mnemonic phrase from a specified number of words. @@ -408,19 +426,6 @@ def decode( $ """, re.VERBOSE ) - @classmethod - def find_language( - cls, mnemonic: List[str], wordlist_path: Optional[Dict[str, str]] = None - ) -> Union[str, Tuple[List[str], str]]: - return [],"" - - - @classmethod - def get_words_list_by_language( - cls, language: str, wordlist_path: Optional[Dict[str, str]] = None - ) -> List[str]: - return [] - @classmethod def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: diff --git a/hdwallet/mnemonics/slip39/wordlist/english.txt b/hdwallet/mnemonics/slip39/wordlist/english.txt new file mode 100644 index 00000000..5673e7ca --- /dev/null +++ b/hdwallet/mnemonics/slip39/wordlist/english.txt @@ -0,0 +1,1024 @@ +academic +acid +acne +acquire +acrobat +activity +actress +adapt +adequate +adjust +admit +adorn +adult +advance +advocate +afraid +again +agency +agree +aide +aircraft +airline +airport +ajar +alarm +album +alcohol +alien +alive +alpha +already +alto +aluminum +always +amazing +ambition +amount +amuse +analysis +anatomy +ancestor +ancient +angel +angry +animal +answer +antenna +anxiety +apart +aquatic +arcade +arena +argue +armed +artist +artwork +aspect +auction +august +aunt +average +aviation +avoid +award +away +axis +axle +beam +beard +beaver +become +bedroom +behavior +being +believe +belong +benefit +best +beyond +bike +biology +birthday +bishop +black +blanket +blessing +blimp +blind +blue +body +bolt +boring +born +both +boundary +bracelet +branch +brave +breathe +briefing +broken +brother +browser +bucket +budget +building +bulb +bulge +bumpy +bundle +burden +burning +busy +buyer +cage +calcium +camera +campus +canyon +capacity +capital +capture +carbon +cards +careful +cargo +carpet +carve +category +cause +ceiling +center +ceramic +champion +change +charity +check +chemical +chest +chew +chubby +cinema +civil +class +clay +cleanup +client +climate +clinic +clock +clogs +closet +clothes +club +cluster +coal +coastal +coding +column +company +corner +costume +counter +course +cover +cowboy +cradle +craft +crazy +credit +cricket +criminal +crisis +critical +crowd +crucial +crunch +crush +crystal +cubic +cultural +curious +curly +custody +cylinder +daisy +damage +dance +darkness +database +daughter +deadline +deal +debris +debut +decent +decision +declare +decorate +decrease +deliver +demand +density +deny +depart +depend +depict +deploy +describe +desert +desire +desktop +destroy +detailed +detect +device +devote +diagnose +dictate +diet +dilemma +diminish +dining +diploma +disaster +discuss +disease +dish +dismiss +display +distance +dive +divorce +document +domain +domestic +dominant +dough +downtown +dragon +dramatic +dream +dress +drift +drink +drove +drug +dryer +duckling +duke +duration +dwarf +dynamic +early +earth +easel +easy +echo +eclipse +ecology +edge +editor +educate +either +elbow +elder +election +elegant +element +elephant +elevator +elite +else +email +emerald +emission +emperor +emphasis +employer +empty +ending +endless +endorse +enemy +energy +enforce +engage +enjoy +enlarge +entrance +envelope +envy +epidemic +episode +equation +equip +eraser +erode +escape +estate +estimate +evaluate +evening +evidence +evil +evoke +exact +example +exceed +exchange +exclude +excuse +execute +exercise +exhaust +exotic +expand +expect +explain +express +extend +extra +eyebrow +facility +fact +failure +faint +fake +false +family +famous +fancy +fangs +fantasy +fatal +fatigue +favorite +fawn +fiber +fiction +filter +finance +findings +finger +firefly +firm +fiscal +fishing +fitness +flame +flash +flavor +flea +flexible +flip +float +floral +fluff +focus +forbid +force +forecast +forget +formal +fortune +forward +founder +fraction +fragment +frequent +freshman +friar +fridge +friendly +frost +froth +frozen +fumes +funding +furl +fused +galaxy +game +garbage +garden +garlic +gasoline +gather +general +genius +genre +genuine +geology +gesture +glad +glance +glasses +glen +glimpse +goat +golden +graduate +grant +grasp +gravity +gray +greatest +grief +grill +grin +grocery +gross +group +grownup +grumpy +guard +guest +guilt +guitar +gums +hairy +hamster +hand +hanger +harvest +have +havoc +hawk +hazard +headset +health +hearing +heat +helpful +herald +herd +hesitate +hobo +holiday +holy +home +hormone +hospital +hour +huge +human +humidity +hunting +husband +hush +husky +hybrid +idea +identify +idle +image +impact +imply +improve +impulse +include +income +increase +index +indicate +industry +infant +inform +inherit +injury +inmate +insect +inside +install +intend +intimate +invasion +involve +iris +island +isolate +item +ivory +jacket +jerky +jewelry +join +judicial +juice +jump +junction +junior +junk +jury +justice +kernel +keyboard +kidney +kind +kitchen +knife +knit +laden +ladle +ladybug +lair +lamp +language +large +laser +laundry +lawsuit +leader +leaf +learn +leaves +lecture +legal +legend +legs +lend +length +level +liberty +library +license +lift +likely +lilac +lily +lips +liquid +listen +literary +living +lizard +loan +lobe +location +losing +loud +loyalty +luck +lunar +lunch +lungs +luxury +lying +lyrics +machine +magazine +maiden +mailman +main +makeup +making +mama +manager +mandate +mansion +manual +marathon +march +market +marvel +mason +material +math +maximum +mayor +meaning +medal +medical +member +memory +mental +merchant +merit +method +metric +midst +mild +military +mineral +minister +miracle +mixed +mixture +mobile +modern +modify +moisture +moment +morning +mortgage +mother +mountain +mouse +move +much +mule +multiple +muscle +museum +music +mustang +nail +national +necklace +negative +nervous +network +news +nuclear +numb +numerous +nylon +oasis +obesity +object +observe +obtain +ocean +often +olympic +omit +oral +orange +orbit +order +ordinary +organize +ounce +oven +overall +owner +paces +pacific +package +paid +painting +pajamas +pancake +pants +papa +paper +parcel +parking +party +patent +patrol +payment +payroll +peaceful +peanut +peasant +pecan +penalty +pencil +percent +perfect +permit +petition +phantom +pharmacy +photo +phrase +physics +pickup +picture +piece +pile +pink +pipeline +pistol +pitch +plains +plan +plastic +platform +playoff +pleasure +plot +plunge +practice +prayer +preach +predator +pregnant +premium +prepare +presence +prevent +priest +primary +priority +prisoner +privacy +prize +problem +process +profile +program +promise +prospect +provide +prune +public +pulse +pumps +punish +puny +pupal +purchase +purple +python +quantity +quarter +quick +quiet +race +racism +radar +railroad +rainbow +raisin +random +ranked +rapids +raspy +reaction +realize +rebound +rebuild +recall +receiver +recover +regret +regular +reject +relate +remember +remind +remove +render +repair +repeat +replace +require +rescue +research +resident +response +result +retailer +retreat +reunion +revenue +review +reward +rhyme +rhythm +rich +rival +river +robin +rocky +romantic +romp +roster +round +royal +ruin +ruler +rumor +sack +safari +salary +salon +salt +satisfy +satoshi +saver +says +scandal +scared +scatter +scene +scholar +science +scout +scramble +screw +script +scroll +seafood +season +secret +security +segment +senior +shadow +shaft +shame +shaped +sharp +shelter +sheriff +short +should +shrimp +sidewalk +silent +silver +similar +simple +single +sister +skin +skunk +slap +slavery +sled +slice +slim +slow +slush +smart +smear +smell +smirk +smith +smoking +smug +snake +snapshot +sniff +society +software +soldier +solution +soul +source +space +spark +speak +species +spelling +spend +spew +spider +spill +spine +spirit +spit +spray +sprinkle +square +squeeze +stadium +staff +standard +starting +station +stay +steady +step +stick +stilt +story +strategy +strike +style +subject +submit +sugar +suitable +sunlight +superior +surface +surprise +survive +sweater +swimming +swing +switch +symbolic +sympathy +syndrome +system +tackle +tactics +tadpole +talent +task +taste +taught +taxi +teacher +teammate +teaspoon +temple +tenant +tendency +tension +terminal +testify +texture +thank +that +theater +theory +therapy +thorn +threaten +thumb +thunder +ticket +tidy +timber +timely +ting +tofu +together +tolerate +total +toxic +tracks +traffic +training +transfer +trash +traveler +treat +trend +trial +tricycle +trip +triumph +trouble +true +trust +twice +twin +type +typical +ugly +ultimate +umbrella +uncover +undergo +unfair +unfold +unhappy +union +universe +unkind +unknown +unusual +unwrap +upgrade +upstairs +username +usher +usual +valid +valuable +vampire +vanish +various +vegan +velvet +venture +verdict +verify +very +veteran +vexed +victim +video +view +vintage +violence +viral +visitor +visual +vitamins +vocal +voice +volume +voter +voting +walnut +warmth +warn +watch +wavy +wealthy +weapon +webcam +welcome +welfare +western +width +wildlife +window +wine +wireless +wisdom +withdraw +wits +wolf +woman +work +worthy +wrap +wrist +writing +wrote +year +yelp +yield +yoga +zero diff --git a/tests/cli/test_cli_seed.py b/tests/cli/test_cli_seed.py index a6fae858..de8bc8e5 100644 --- a/tests/cli/test_cli_seed.py +++ b/tests/cli/test_cli_seed.py @@ -80,7 +80,8 @@ def test_cli_seed(data, cli_tester): cli = cli_tester.invoke( cli_main, cli_args ) + print( f"cli output: {cli.output}" ) output = json.loads(cli.output) assert output["client"] == client - assert output["seed"] == seed \ No newline at end of file + assert output["seed"] == seed diff --git a/tests/data/json/seeds.json b/tests/data/json/seeds.json index 990c3ad7..0b09da11 100644 --- a/tests/data/json/seeds.json +++ b/tests/data/json/seeds.json @@ -507,17 +507,17 @@ "non-passphrase-seed": "ffffffffffffffffffffffffffffffff", "passphrases": null }, - "hex 128": { + "hex-one-twenty-eight": { "mnemonic": "ffffffffffffffffffffffffffffffff", "non-passphrase-seed": "ffffffffffffffffffffffffffffffff", "passphrases": null }, - "hex 256": { + "hex-two-fifty-six": { "mnemonic": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "passphrases": null }, - "hex 512": { + "hex-five-twelve": { "mnemonic": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "passphrases": null diff --git a/tests/data/raw/languages.txt b/tests/data/raw/languages.txt index 4ac8bad4..4497d572 100644 --- a/tests/data/raw/languages.txt +++ b/tests/data/raw/languages.txt @@ -46,3 +46,6 @@ Russian Spanish +SLIP39 Languages +------------------ +English diff --git a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py index 8ee27c8e..9663d35c 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py @@ -56,8 +56,13 @@ def test_slip39_language(): def test_slip39_mnemonics(): entropy = "ff"*(256//8) - mnemonics = SLIP39Mnemonic.encode(entropy=entropy, language="") - mnemonics_list = SLIP39Mnemonic.normalize(mnemonics) - recovered = SLIP39Mnemonic.decode(mnemonics_list) + mnemonic = SLIP39Mnemonic.encode(entropy=entropy, language="") + mnemonic_list = SLIP39Mnemonic.normalize(mnemonic) + recovered = SLIP39Mnemonic.decode(mnemonic_list) assert recovered == entropy + slip39 = SLIP39Mnemonic(mnemonic) + assert slip39._mnemonic == mnemonic_list + assert slip39.mnemonic() == mnemonic + + diff --git a/tests/hdwallet/seeds/test_seeds_slip39.py b/tests/hdwallet/seeds/test_seeds_slip39.py index cba7ef1f..6effe60b 100644 --- a/tests/hdwallet/seeds/test_seeds_slip39.py +++ b/tests/hdwallet/seeds/test_seeds_slip39.py @@ -25,11 +25,11 @@ def test_slip39_seeds(data): # passphrase, but this is not generally supported by hardware wallets supporting # SLIP-39, such as the Trezor. This is unfortunate, as it prevents backing up BIP-39 # derived seeds including the passphrase. - try: - mnemonic = BIP39Mnemonic(mnemonic=mnemonic) - except Exception: - logging.exception("Failed to interpret %s as BIP-39 Mnemonic", mnemonic) - pass + # try: + # mnemonic = BIP39Mnemonic(mnemonic=mnemonic) + # except Exception: + # logging.exception("Failed to interpret %s as BIP-39 Mnemonic", mnemonic) + # pass assert SLIP39Seed.from_mnemonic( mnemonic = mnemonic ) == data["seeds"]["SLIP39"][words][lang]["non-passphrase-seed"] From 3d4208adc46fa3f7599609b92c707c51b53b6cc9 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Wed, 20 Aug 2025 11:25:53 -0600 Subject: [PATCH 11/38] Clean up multi-mnemonic handling for SLIP-39 --- CONTRIBUTING.md | 23 ++++++- Makefile | 4 +- README.md | 6 +- flake.nix | 4 +- hdwallet/cli/__main__.py | 21 ++++-- hdwallet/cli/generate/mnemonic.py | 7 +- hdwallet/mnemonics/__init__.py | 3 +- hdwallet/mnemonics/slip39/__init__.py | 4 +- hdwallet/mnemonics/slip39/mnemonic.py | 68 +++++++++++++------ hdwallet/seeds/slip39.py | 16 +++-- requirements.txt | 1 + requirements/slip39.txt | 2 - setup.py | 3 +- tests/data/json/seeds.json | 12 +++- .../mnemonics/test_mnemonics_slip39.py | 62 ++++++++++++++++- tests/hdwallet/seeds/test_seeds_slip39.py | 5 -- 16 files changed, 187 insertions(+), 54 deletions(-) delete mode 100644 requirements/slip39.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f75d0161..c687dae4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,28 @@ with the owners of this repository before making a change. ## Development -To get started, just fork this repo, clone it locally, and run: +### Using Nix (Recommended) + +The easiest way to get a complete development environment is to use Nix. This approach provides the correct Python version and all dependencies automatically. + +If you have Nix installed, you can get a full development/runtime environment by running: + +``` +make nix-venv +``` + +This will activate an interactive shell with the Nix environment and Python virtual environment set up. + +To run specific commands in the Nix + venv environment, use the pattern `make nix-venv-target`: + +``` +make nix-venv-test # Run tests in Nix + venv environment +make nix-venv-install # Install package in Nix + venv environment +``` + +### Manual Setup + +Alternatively, you can set up the development environment manually. Fork this repo, clone it locally, and run: ``` pip install -e .[cli,tests,docs] diff --git a/Makefile b/Makefile index 1a23bf4d..9b65c768 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ $(WHEEL): FORCE # Install from wheel, including all optional extra dependencies (doesn't include dev) install: $(WHEEL) FORCE - $(PYTHON) -m pip install --force-reinstall $<[slip39,cli,docs,tests] + $(PYTHON) -m pip install --force-reinstall $<[cli,tests,docs] # Install from requirements/*; eg. install-dev install-%: FORCE @@ -86,7 +86,7 @@ $(VENV): @echo; echo "*** Building $@ VirtualEnv..." @rm -rf $@ && $(PYTHON) -m venv $(VENV_OPTS) $@ && sed -i -e '1s:^:. $$HOME/.bashrc\n:' $@/bin/activate \ && source $@/bin/activate \ - && make install-dev + && make install-dev install print-%: @echo $* = $($*) diff --git a/README.md b/README.md index 5ff4f6a4..a4ddd3f4 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Python-based library implementing a Hierarchical Deterministic (HD) Wallet gener | Features | Protocols | |:------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Cryptocurrencies | #supported-cryptocurrencies | -| Entropies | `Algorand`, `BIP39`, `Electrum-V1`, `Electrum-V2`, `Monero` | -| Mnemonics | `Algorand`, `BIP39`, `Electrum-V1`, `Electrum-V2`, `Monero` | -| Seeds | `Algorand`, `BIP39`, `Cardano`, `Electrum-V1`, `Electrum-V2`, `Monero` | +| Entropies | `Algorand`, `BIP39`, `SLIP39`, `Electrum-V1`, `Electrum-V2`, `Monero` | +| Mnemonics | `Algorand`, `BIP39`, `SLIP39`, `Electrum-V1`, `Electrum-V2`, `Monero` | +| Seeds | `Algorand`, `BIP39`, `SLIP39`, `Cardano`, `Electrum-V1`, `Electrum-V2`, `Monero` | | Elliptic Curve Cryptography's | `Kholaw-Ed25519`, `SLIP10-Ed25519`, `SLIP10-Ed25519-Blake2b`, `SLIP10-Ed25519-Monero`, `SLIP10-Nist256p1`, `SLIP10-Secp256k1` | | Hierarchical Deterministic's | `Algorand`, `BIP32`, `BIP44`, `BIP49`, `BIP84`, `BIP86`, `BIP141`, `Cardano`, `Electrum-V1`, `Electrum-V2`, `Monero` | | Derivations | `BIP44`, `BIP49`, `BIP84`, `BIP86`, `CIP1852`, `Custom`, `Electrum`, `Monero`, `HDW (Our own custom derivation)` | diff --git a/flake.nix b/flake.nix index 67e8fd6f..b196853c 100644 --- a/flake.nix +++ b/flake.nix @@ -36,14 +36,14 @@ openssh bash bash-completion - + # All Python versions with packages #python310Env python311Env python312Env python313Env ]; - + shellHook = '' echo "Welcome to the multi-Python development environment!" echo "Available Python interpreters:" diff --git a/hdwallet/cli/__main__.py b/hdwallet/cli/__main__.py index d216ac0d..04538959 100644 --- a/hdwallet/cli/__main__.py +++ b/hdwallet/cli/__main__.py @@ -26,6 +26,15 @@ from .list.strengths import list_strengths +def process_kwargs(kwargs): + """Process mnemonic arguments handling both multiple flags and explicit \\n sequences.""" + if kwargs.get("mnemonic"): + # Join multiple mnemonic arguments, then split on any literal raw r"\n" and rejoin + combined = "\n".join(kwargs["mnemonic"]) + kwargs["mnemonic"] = "\n".join(combined.split(r'\n')) + return kwargs + + def current_version( context: click.core.Context, option: click.core.Option, value: bool ) -> None: @@ -124,7 +133,7 @@ def cli_mnemonic(**kwargs) -> None: "-c", "--client", type=str, default="BIP39", help="Set Seed client", show_default=True ) @click.option( - "-m", "--mnemonic", type=str, default=None, help="Set Seed mnemonic", show_default=True + "-m", "--mnemonic", multiple=True, help="Set Seed mnemonic(s)" ) @click.option( "-p", "--passphrase", type=str, default=None, help="Set Seed passphrase", show_default=True @@ -136,7 +145,7 @@ def cli_mnemonic(**kwargs) -> None: "-mt", "--mnemonic-type", type=str, default="standard", help="Set Mnemonic type for Electrum-V2", show_default=True ) def cli_seed(**kwargs) -> None: - return generate_seed(**kwargs) + return generate_seed(**process_kwargs(kwargs)) @cli_main.command( @@ -161,7 +170,7 @@ def cli_seed(**kwargs) -> None: "-mc", "--mnemonic-client", type=str, default="BIP39", help="Select Mnemonic client", show_default=True ) @click.option( - "-m", "--mnemonic", type=str, default=None, help="Set Master key from Mnemonic words", show_default=True + "-m", "--mnemonic", multiple=True, help="Set Master key from mnemonic(s)" ) @click.option( "-l", "--language", type=str, default="english", help="Select Language for mnemonic", show_default=True @@ -269,7 +278,7 @@ def cli_seed(**kwargs) -> None: "-ex", "--exclude", type=str, default="", help="Set Exclude keys from dumped", show_default=True ) def cli_dump(**kwargs) -> None: # cli_dumps(max_content_width=120) - return dump(**kwargs) + return dump(**process_kwargs(kwargs)) @cli_main.command( @@ -294,7 +303,7 @@ def cli_dump(**kwargs) -> None: # cli_dumps(max_content_width=120) "-mc", "--mnemonic-client", type=str, default="BIP39", help="Select Mnemonic client", show_default=True ) @click.option( - "-m", "--mnemonic", type=str, default=None, help="Set Master key from Mnemonic words", show_default=True + "-m", "--mnemonic", multiple=True, help="Set Master key from mnemonic(s)" ) @click.option( "-l", "--language", type=str, default="english", help="Select Language for mnemonic", show_default=True @@ -414,7 +423,7 @@ def cli_dump(**kwargs) -> None: # cli_dumps(max_content_width=120) "-de", "--delimiter", type=str, default=" ", help="Set Delimiter for CSV", show_default=True ) def cli_dumps(**kwargs) -> None: # cli_dumps(max_content_width=120) - return dumps(**kwargs) + return dumps(**process_kwargs(kwargs)) @cli_main.group( diff --git a/hdwallet/cli/generate/mnemonic.py b/hdwallet/cli/generate/mnemonic.py index e18e61a9..f4c495bd 100644 --- a/hdwallet/cli/generate/mnemonic.py +++ b/hdwallet/cli/generate/mnemonic.py @@ -12,6 +12,7 @@ IMnemonic, AlgorandMnemonic, ALGORAND_MNEMONIC_WORDS, ALGORAND_MNEMONIC_LANGUAGES, BIP39Mnemonic, BIP39_MNEMONIC_WORDS, BIP39_MNEMONIC_LANGUAGES, + SLIP39Mnemonic, SLIP39_MNEMONIC_WORDS, SLIP39_MNEMONIC_LANGUAGES, ElectrumV1Mnemonic, ELECTRUM_V1_MNEMONIC_WORDS, ELECTRUM_V1_MNEMONIC_LANGUAGES, ElectrumV2Mnemonic, ELECTRUM_V2_MNEMONIC_WORDS, ELECTRUM_V2_MNEMONIC_LANGUAGES, MoneroMnemonic, MONERO_MNEMONIC_WORDS, MONERO_MNEMONIC_LANGUAGES, @@ -32,6 +33,8 @@ def generate_mnemonic(**kwargs) -> None: language: str = ALGORAND_MNEMONIC_LANGUAGES.ENGLISH elif kwargs.get("client") == BIP39Mnemonic.name(): language: str = BIP39_MNEMONIC_LANGUAGES.ENGLISH + elif kwargs.get("client") == SLIP39Mnemonic.name(): + language: str = SLIP39_MNEMONIC_LANGUAGES.ENGLISH elif kwargs.get("client") == ElectrumV1Mnemonic.name(): language: str = ELECTRUM_V1_MNEMONIC_LANGUAGES.ENGLISH elif kwargs.get("client") == ElectrumV2Mnemonic.name(): @@ -46,6 +49,8 @@ def generate_mnemonic(**kwargs) -> None: words: int = ALGORAND_MNEMONIC_WORDS.TWENTY_FIVE elif kwargs.get("client") == BIP39Mnemonic.name(): words: int = BIP39_MNEMONIC_WORDS.TWELVE + elif kwargs.get("client") == SLIP39Mnemonic.name(): + words: int = SLIP39_MNEMONIC_WORDS.TWENTY elif kwargs.get("client") == ElectrumV1Mnemonic.name(): words: int = ELECTRUM_V1_MNEMONIC_WORDS.TWELVE elif kwargs.get("client") == ElectrumV2Mnemonic.name(): @@ -55,7 +60,7 @@ def generate_mnemonic(**kwargs) -> None: else: words: int = kwargs.get("words") - if not MNEMONICS.mnemonic(name=kwargs.get("client")).is_valid_language(language=language): + if not MNEMONICS.mnemonic(name=kwargs.get("client")).is_valid_language(language): click.echo(click.style( f"Wrong {kwargs.get('client')} mnemonic language, " f"(expected={MNEMONICS.mnemonic(name=kwargs.get('client')).languages}, got='{language}')" diff --git a/hdwallet/mnemonics/__init__.py b/hdwallet/mnemonics/__init__.py index 938e9643..8d2c7b00 100644 --- a/hdwallet/mnemonics/__init__.py +++ b/hdwallet/mnemonics/__init__.py @@ -16,7 +16,7 @@ BIP39Mnemonic, BIP39_MNEMONIC_WORDS, BIP39_MNEMONIC_LANGUAGES ) from .slip39 import ( - SLIP39Mnemonic + SLIP39Mnemonic, SLIP39_MNEMONIC_WORDS, SLIP39_MNEMONIC_LANGUAGES ) from .electrum import ( ElectrumV1Mnemonic, ELECTRUM_V1_MNEMONIC_WORDS, ELECTRUM_V1_MNEMONIC_LANGUAGES, @@ -121,6 +121,7 @@ def is_mnemonic(cls, name) -> bool: "IMnemonic", "ALGORAND_MNEMONIC_WORDS", "ALGORAND_MNEMONIC_LANGUAGES", "BIP39_MNEMONIC_WORDS", "BIP39_MNEMONIC_LANGUAGES", + "SLIP39_MNEMONIC_WORDS", "SLIP39_MNEMONIC_LANGUAGES", "ELECTRUM_V1_MNEMONIC_WORDS", "ELECTRUM_V1_MNEMONIC_LANGUAGES", "ELECTRUM_V2_MNEMONIC_WORDS", "ELECTRUM_V2_MNEMONIC_LANGUAGES", "ELECTRUM_V2_MNEMONIC_TYPES", "MONERO_MNEMONIC_WORDS", "MONERO_MNEMONIC_LANGUAGES", diff --git a/hdwallet/mnemonics/slip39/__init__.py b/hdwallet/mnemonics/slip39/__init__.py index a0eca5ee..73f1a9bb 100644 --- a/hdwallet/mnemonics/slip39/__init__.py +++ b/hdwallet/mnemonics/slip39/__init__.py @@ -7,10 +7,12 @@ from typing import List from .mnemonic import ( - SLIP39Mnemonic + SLIP39Mnemonic, SLIP39_MNEMONIC_WORDS, SLIP39_MNEMONIC_LANGUAGES ) __all__: List[str] = [ "SLIP39Mnemonic", + "SLIP39_MNEMONIC_WORDS", + "SLIP39_MNEMONIC_LANGUAGES" ] diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index 95ca2d5d..16ce2fd4 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -38,7 +38,6 @@ class SLIP39_MNEMONIC_LANGUAGES: ENGLISH: str = "english" - def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tuple[int,int]]: """Parse a SLIP-39 group specification; a name up to the first digit, ( or /, then a threshold/count spec: @@ -109,9 +108,9 @@ def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tu def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[str,int],Tuple[int,int]]]: """ - Parse a SLIP-39 language dialect specification. + Parse a SLIP-39 language dialect specification. - optional sep default comma-separated mnemonic + optional sep default comma-separated mnemonic name a secret spec group thresholds (optional if no /) --------------------- --- -------------------------------------- - @@ -135,7 +134,6 @@ def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[ "g2": (t2/n2), } } - """ s_match = language_parser.RE.match(language) @@ -326,6 +324,16 @@ def from_entropy(cls, entropy: Union[str, bytes, IEntropy], language: str, **kwa "Invalid entropy instance", expected=[str, bytes,]+list(ENTROPIES.dictionary.values()), got=type(entropy) ) + + @classmethod + def is_valid_language(cls, language: str) -> bool: + try: + language_parser(language) + return True + except Exception as exc: + return False + + @classmethod def encode( cls, @@ -402,31 +410,47 @@ def decode( """ mnemonic_list: List[str] = cls.normalize(mnemonic) - mnemonic_words, = filter(lambda words: len(mnemonic_list) % words == 0, cls.words_list) - mnemonic_chunks: Iterable[List[str]] = zip(*[iter(mnemonic_list)] * mnemonic_words) - mnemonic: Iterable[str] = map(" ".join, mnemonic_chunks) - recovery = RecoveryState() try: - while not recovery.is_complete(): - recovery.add_share(Share.from_mnemonic(next(mnemonic))) - return bytes_to_string(recovery.recover(passphrase.encode('UTF-8'))) + mnemonic_words, = filter(lambda words: len(mnemonic_list) % words == 0, cls.words_list) + mnemonic_chunks: Iterable[List[str]] = zip(*[iter(mnemonic_list)] * mnemonic_words) + mnemonic_lines: Iterable[str] = map(" ".join, mnemonic_chunks) + recovery = RecoveryState() + for line in mnemonic_lines: + recovery.add_share(Share.from_mnemonic(line)) + if recovery.is_complete(): + break + else: + raise ValueError( + f"Incomplete: found {recovery.groups_complete()}" + + f"/{recovery.parameters.group_threshold} groups and " + + ", ".join( + "/".join(map(lambda x: str(x) if x >= 0 else "?", recovery.group_status(g))) + for g in range(recovery.parameters.group_count) + ) + + " mnemonics required" + ) + entropy = bytes_to_string(recovery.recover(passphrase.encode('UTF-8'))) + return entropy except Exception as exc: - raise MnemonicError(f"Failed to recover SLIP-39 Mnemonics", detail=exc) + raise MnemonicError(f"Failed to recover SLIP-39 Mnemonics", detail=exc) from exc NORMALIZE = re.compile( r""" ^ + \s* ( - [\w\d\s]* [^\w\d\s] # Group 1 { + [\w\d\s]* [^\w\d\s] # Group 1 { <-- a single non-word/space/digit separator allowed )? + \s* ( - [\w\s]* # word word ... - ) + [\w\s]*\w # word word ... word <-- must end with non-whitespace (strips whitespace) + )? + \s* $ """, re.VERBOSE ) - + @classmethod def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: """Filter the supplied lines of mnemonics, rejecting groups of mnemonics not evenly divisible by @@ -454,9 +478,12 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: errors = [] if isinstance( mnemonic, str ): mnemonic_list: List[str] = [] - mnemonic_lines = filter(None, mnemonic.strip().split("\n")) - for line_no,m in enumerate( map( cls.NORMALIZE.match, mnemonic_lines)): + for line_no,m in enumerate( map( cls.NORMALIZE.match, mnemonic.split("\n"))): + if not m: + errors.append( f"@L{line_no+1}; unrecognized mnemonic ignored" ) + continue + pref,mnem = m.groups() if not mnem: # Blank lines or lines without Mnemonic skipped continue @@ -464,7 +491,7 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: if len(mnem) in cls.words_list: mnemonic_list.extend(mnem) else: - errors.append( f"@L{line_no}; odd {len(mnem)}-word mnemonic ignored" ) + errors.append( f"@L{line_no+1}; odd {len(mnem)}-word mnemonic ignored" ) else: mnemonic_list: List[str] = mnemonic @@ -480,6 +507,3 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: ) return mnemonic_list - - - diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index e0b91d27..66500d98 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -44,7 +44,8 @@ def name(cls) -> str: def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None) -> str: """Converts a mnemonic phrase to its corresponding raw entropy. - The Mnemonic representation for SLIP-39 seeds is simple hex. + The Mnemonic representation for SLIP-39 seeds is simple hex, and must be of the supported + SLIP-39 entropy sizes: 128, 256 or 512 bits. To support the backup and recovery of BIP-39 mnemonic phrases to/from SLIP-39, we accept a BIP39 IMnemonic or mnemonic phrase, and recover the underlying (original) entropy encoded by @@ -69,9 +70,11 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str if not isinstance(mnemonic, IMnemonic): # Not an IMnemonic; must be a str. Try the supported mnemonic encodings we'll allow for # SLIP39 seeds, converting the mnemonic phrase to an IMnemonic if recognized. + # + # TODO: Add other Seed entropy Mnemonics allowed_entropy = [ + SLIP39Mnemonic, BIP39Mnemonic, - SLIP39Mnemonic, # ... ] for M in allowed_entropy: @@ -80,8 +83,13 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str break else: raise EntropyError( - "Invalid entropy instance", expected=[str, ] + allowed_entropy, got=type(mnemonic) + "Invalid entropy instance", expected=[str, ] + allowed_entropy, got=type(mnemonic), ) # Some kind of IMnemonic (eg. a BIP39Mnemonic); get and return its raw entropy as hex - return mnemonic.decode(mnemonic.mnemonic()) + entropy = mnemonic.decode(mnemonic.mnemonic()) + if len(entropy) * 4 not in SLIP39Mnemonic.words_to_entropy_strength.values(): + raise EntropyError( + "Invalid entropy size in bits", expected=SLIP39Mnemonic.words_to_entropy_strength.values(), got=len(entropy) * 4, + ) + return entropy diff --git a/requirements.txt b/requirements.txt index 7ce138ce..75499c28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ coincurve>=20.0.0,<21 pynacl>=1.5.0,<2 base58>=2.1.1,<3 cbor2>=5.6.1,<6 +shamir-mnemonic>=0.3,<0.6 diff --git a/requirements/slip39.txt b/requirements/slip39.txt deleted file mode 100644 index c6bd2e24..00000000 --- a/requirements/slip39.txt +++ /dev/null @@ -1,2 +0,0 @@ -#shamir-mnemonic-slip39>=0.4.2,<0.5 -shamir-mnemonic>=0.3,<0.4 diff --git a/setup.py b/setup.py index 3feb6e34..b34cdaf7 100644 --- a/setup.py +++ b/setup.py @@ -58,8 +58,7 @@ def get_requirements(name: str) -> List[str]: extras_require=dict( cli=get_requirements(name="requirements/cli"), docs=get_requirements(name="requirements/docs"), - tests=get_requirements(name="requirements/tests"), - slip39=get_requirements(name="requirements/slip39") + tests=get_requirements(name="requirements/tests") ), classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/data/json/seeds.json b/tests/data/json/seeds.json index 0b09da11..163e5a10 100644 --- a/tests/data/json/seeds.json +++ b/tests/data/json/seeds.json @@ -521,7 +521,17 @@ "mnemonic": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "passphrases": null - } + }, + "five-twelve-one-of-one": { + "mnemonic": "edge typical academic academic academic boring radar cluster domestic ticket fumes remove velvet fluff video crazy chest average script universe exhaust remind helpful lamp declare garlic repeat unknown bucket adorn sled adult triumph source divorce premium genre glimpse level listen ancestor wildlife writing document wrist judicial medical detect frost leaves language jerky increase glasses extra alto deploy demand greatest", + "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "passphrases": null + }, + "five-twelve-two-of-two": { + "mnemonic": "bracelet cleanup acrobat easy acquire critical exceed agency verify envy story best facility process syndrome discuss health twin ugly spew unknown spider level academic lying large slap venture hairy election legal away negative easel learn item trial miracle hour provide survive pleasure clock acne faint priest loyalty sunlight award forget ambition failure threaten kind dictate lips branch slice space\nbracelet cleanup beard easy acne visitor scroll finger skin trash browser union energy endorse scramble staff sprinkle salt alpha dive sweater pickup cage obtain leader clothes acid dive frozen category desert thorn music western home owner manager apart much hobo march adequate eraser crazy short smith force flame primary phrase sprinkle frost trial crunch fancy piece crunch scroll triumph", + "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "passphrases": null + } } }, "Cardano": { diff --git a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py index 9663d35c..7cf3d07a 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py @@ -1,8 +1,10 @@ +from hdwallet.exceptions import MnemonicError from hdwallet.mnemonics.slip39.mnemonic import ( SLIP39Mnemonic, language_parser, group_parser ) + def test_slip39_language(): # Any name; no spec --> simplest 1/1 group encoding yielding single mnemonic @@ -53,16 +55,74 @@ def test_slip39_language(): 6: (8,16), }, } - + def test_slip39_mnemonics(): + + # Ensure our prefix and whitespace handling works correctly + assert SLIP39Mnemonic.NORMALIZE.match( + " Group 1 { word word" + ).groups() == ("Group 1 {","word word") + + assert SLIP39Mnemonic.NORMALIZE.match( + " Group 1 { word word " + ).groups() == ("Group 1 {","word word") + + assert SLIP39Mnemonic.NORMALIZE.match( + " word word " + ).groups() == (None,"word word") + + assert SLIP39Mnemonic.NORMALIZE.match( + " Group 1 { " + ).groups() == ("Group 1 {",None) + entropy = "ff"*(256//8) mnemonic = SLIP39Mnemonic.encode(entropy=entropy, language="") mnemonic_list = SLIP39Mnemonic.normalize(mnemonic) recovered = SLIP39Mnemonic.decode(mnemonic_list) assert recovered == entropy + expected_entropy = "ff" * (512 // 8) # 64 bytes of 0xFF + slip39 = SLIP39Mnemonic(mnemonic) assert slip39._mnemonic == mnemonic_list assert slip39.mnemonic() == mnemonic + for mnemonic in [ + "curly agency academic academic academic boring radar cluster domestic ticket fumes remove velvet fluff video crazy chest average script universe exhaust remind helpful lamp declare garlic repeat unknown bucket adorn sled adult triumph source divorce premium genre glimpse level listen ancestor wildlife writing document wrist judicial medical detect frost leaves language jerky increase glasses extra alto burden iris swing", + "trend cleanup acrobat easy acid military timber boundary museum dictate argue always grasp bundle welcome silent campus exhaust snake magazine kitchen surface unfold theory adequate gasoline exotic counter fantasy magazine slow mailman metric thumb listen ruler elite mansion diet hybrid withdraw swing makeup repeat glasses density express ting estimate climate scholar loyalty unfold bumpy ecology briefing much fiscal mental\ntrend cleanup beard easy acne extra profile window craft custody owner plot inherit injury starting iris talent curious squeeze retreat density decision hush rainbow extra grumpy humidity income should spray elevator drove large source game pajamas sprinkle dining security class adapt credit therapy verify realize retailer scatter suitable stick hearing lecture mountain dragon talent medal decision equip cleanup aircraft", + "salon email acrobat romp acid lunar rival view daughter exchange privacy pickup moisture forbid welcome amount estimate therapy sled theory says member scroll sister smell erode scene tension glance laden ting cricket apart senior legend transfer describe crowd exceed saver lilac episode cluster pipeline sniff window loyalty manual behavior raspy problem fraction story playoff scroll aunt benefit element execute\nsalon email beard romp acquire vocal plan aviation nervous package unhappy often goat forward closet material fortune fitness wireless terminal slap resident aunt artist source cover perfect grant military ruin taught depend criminal theater decision standard salary priority equation license prisoner rhyme indicate academic shaft express kernel airport tolerate market owner erode dance orange beaver distance smug plunge level\nsalon email ceramic roster academic spark starting says phantom tension saver erode ugly smoking crazy screw pumps display funding fortune mixture ancestor industry glad paces junk laden timber hunting secret program ruin gather clogs legal sugar adjust check crazy genuine predator national swimming twice admit desert system sidewalk check class spelling early morning liberty grief election antenna merchant adjust\nsalon email ceramic scared acid cultural object wildlife percent include wealthy geology capture lift evidence envy identify game guilt curly garbage reaction early scatter practice metric mild earth subject axis verdict juice sled dominant ranked blimp sympathy credit example typical float prisoner ting paces husband adequate amuse display worthy amuse depict civil learn modify lecture mother paid evil stadium\nsalon email ceramic shadow acquire critical ugly desire piece romp piece olympic benefit cargo forbid superior credit username library usher beyond include verify pipeline volume pistol ajar mild carbon acrobat receiver decrease champion calcium flea email picture funding tracks junior fishing thorn regret lily tofu decent romp hazard loud cards peaceful alien retreat single pregnant unfold trial wrist jury\nsalon email ceramic sister acne spirit parking aquatic phrase fact order racism tendency example disaster finance trip multiple ranked lobe tackle smirk regular auction satoshi elephant traveler estimate practice sprinkle true making manual adjust herald mama jacket fishing lecture volume phantom symbolic liberty usher moment alcohol born nervous flip desert element budget pink switch envy discuss laden check promise\nsalon email decision round acquire voting damage briefing emphasis parking airport nylon umbrella coding fake cylinder chubby bolt superior client shame museum reward domain briefing forget guilt group leaf teacher that remind blind judicial soul library dismiss guard provide smoking robin blue focus relate tricycle flexible meaning painting venture trip manager stay flexible rebuild group elephant papa dismiss activity\nsalon email decision scatter acid idle veteran knife thorn theory remember volume cluster writing drove process staff usual sprinkle observe sympathy says birthday lunar leaves salary belong license submit anxiety award spray body victim domestic solution decent geology huge preach human scared desktop email frost verify says predator debris peasant burden swing owner safari reaction broken glimpse jacket deal\nsalon email decision shaft academic breathe mental capital midst guest tracks bolt twin change usual rescue profile taxi paces penalty vitamins emphasis story acquire exhaust salt quantity junction shame midst saver peanut acquire trash duke spend remember predator miracle vintage rich multiple story inmate depend example together blimp coding depart acid diminish petition sister mountain explain thumb density kidney\nsalon email decision skin acne owner finance kernel deal crazy fortune kernel cause warn ordinary document forward alto mixed burning theater axis hybrid review squeeze force shelter owner minister jump darkness smith advance greatest stadium listen prune prisoner exceed medal hospital else race lying liquid tolerate preach capture therapy junction method demand glasses relate emerald blind club income exceed\nsalon email decision snake acne repair sidewalk window video knit resident alien window weapon chubby pacific segment artwork nuclear erode thorn replace wits snapshot founder shaped quiet spray sled depend decent cage income pecan estimate purchase frequent trash chew luxury glimpse category move pipeline scout snake source entrance laundry skunk gravity briefing ancestor hormone security husky snake nylon prospect\nsalon email decision spider academic dramatic axis overall finger early alive health decent ceiling explain capture deploy trip mother viral valid unwrap filter holiday saver fake sharp decorate mustang stay survive hybrid hybrid cowboy peanut that findings umbrella worthy venture quick various watch filter impact jury paid elevator retreat literary viral capacity skin bumpy blue criminal behavior surface legal", + ]: + assert SLIP39Mnemonic.is_valid(mnemonic) + slip39 = SLIP39Mnemonic(mnemonic) + assert slip39.words() == 59 + assert SLIP39Mnemonic.decode(slip39.mnemonic()) == expected_entropy + + # Let's make sure we can detect missing mnemonics reliably. With random subsets, we should + # either decode the Mnemonic, or get a MnemonicError detailing what was missing + mnemonic_list = mnemonic.split('\n') + import random + + # Start with full list size and decrease + for subset_size in range(len(mnemonic_list), 0, -1): + # Create random subset + subset = random.sample(mnemonic_list, subset_size) + + try: + result = SLIP39Mnemonic.decode( '\n'.join(subset) ) + # If decode succeeds, verify it returns expected entropy + assert result == expected_entropy, ( + f"Subset size {subset_size}: Expected entropy {expected_entropy}, " + f"got {result}" + ) + except MnemonicError as e: + # Verify it's the expected "Incomplete" error + assert "Incomplete: found" in str(e), ( + f"Subset size {subset_size}: Expected 'Incomplete; found' error, " + f"got: {str(e)}" + ) + except Exception as e: + # Unexpected error type + pytest.fail( + f"Subset size {subset_size}: Unexpected error type {type(e)}: {e}" + ) diff --git a/tests/hdwallet/seeds/test_seeds_slip39.py b/tests/hdwallet/seeds/test_seeds_slip39.py index 6effe60b..9e8a338e 100644 --- a/tests/hdwallet/seeds/test_seeds_slip39.py +++ b/tests/hdwallet/seeds/test_seeds_slip39.py @@ -25,11 +25,6 @@ def test_slip39_seeds(data): # passphrase, but this is not generally supported by hardware wallets supporting # SLIP-39, such as the Trezor. This is unfortunate, as it prevents backing up BIP-39 # derived seeds including the passphrase. - # try: - # mnemonic = BIP39Mnemonic(mnemonic=mnemonic) - # except Exception: - # logging.exception("Failed to interpret %s as BIP-39 Mnemonic", mnemonic) - # pass assert SLIP39Seed.from_mnemonic( mnemonic = mnemonic ) == data["seeds"]["SLIP39"][words][lang]["non-passphrase-seed"] From 8ceedf1f22fd4ae080b18032f3acead549ba670b Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Wed, 20 Aug 2025 15:26:49 -0600 Subject: [PATCH 12/38] Update version to 3.7.0 and add to CHANGELOG.md --- CHANGELOG.md | 13 +++++++++++++ hdwallet/info.py | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0478a852..d994ad70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [v3.7.0](https://github.com/hdwallet-io/python-hdwallet/tree/v3.7.0) (2025-08-20) + +[Full Changelog](https://github.com/hdwallet-io/python-hdwallet/compare/v3.6.1...v3.7.0) + +**New Additions:** + +- Add: SLIP-39 mnemonic and seed support with multi-language wordlist handling +- Add: Raw entropy support for SLIP-39 seeds + +**Enhancements:** + +- Improve: SLIP-39 mnemonic implementation and multi-mnemonic handling + ## [v3.6.1](https://github.com/hdwallet-io/python-hdwallet/tree/v3.6.1) (2025-08-04) [Full Changelog](https://github.com/hdwallet-io/python-hdwallet/compare/v3.6.0...v3.6.1) diff --git a/hdwallet/info.py b/hdwallet/info.py index 75ce3d41..7abf5f72 100644 --- a/hdwallet/info.py +++ b/hdwallet/info.py @@ -7,7 +7,7 @@ from typing import List __name__: str = "hdwallet" -__version__: str = "v3.6.1" +__version__: str = "v3.7.0" __license__: str = "MIT" __author__: str = "Meheret Tesfaye Batu" __email__: str = "meherett.batu@gmail.com" @@ -20,7 +20,7 @@ __keywords__: List[str] = [ "ecc", "kholaw", "slip10", "ed25519", "nist256p1", "secp256k1", # ECC keywords "hd", "bip32", "bip44", "bip49", "bip84", "bip86", "bip141", "monero", "cardano", # HD keywords - "entropy", "mnemonic", "seed", "bip39", "algorand", "electrum", # Entropy, Mnemonic and Seed keywords + "entropy", "mnemonic", "seed", "bip39", "slip39", "algorand", "electrum", # Entropy, Mnemonic and Seed keywords "cryptocurrencies", "bitcoin", "ethereum", "cryptography", "cli", "cip1852" # Other keywords ] __websites__: List[str] = [ From 07ff88ea4e276dde2bc95c8d60ae64cb6e33b3a5 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Wed, 20 Aug 2025 19:45:33 -0600 Subject: [PATCH 13/38] Update shamir_mnemonic for compatibility with new recovery APIs --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 75499c28..d2f5c373 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,4 @@ coincurve>=20.0.0,<21 pynacl>=1.5.0,<2 base58>=2.1.1,<3 cbor2>=5.6.1,<6 -shamir-mnemonic>=0.3,<0.6 +shamir-mnemonic-slip39>=0.4,<0.5 From 3ea38ddbd715ddb5f13c2353764fa2671aa3b605 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Thu, 21 Aug 2025 08:26:55 -0600 Subject: [PATCH 14/38] Clean up, add analyze target --- MANIFEST.in | 1 - Makefile | 7 ++- hdwallet/cli/generate/seed.py | 4 +- hdwallet/entropies/ientropy.py | 4 +- hdwallet/mnemonics/__init__.py | 14 ++--- hdwallet/mnemonics/algorand/mnemonic.py | 4 +- hdwallet/mnemonics/electrum/v1/mnemonic.py | 2 - hdwallet/mnemonics/electrum/v2/mnemonic.py | 2 - hdwallet/mnemonics/imnemonic.py | 4 +- hdwallet/mnemonics/monero/mnemonic.py | 2 - hdwallet/mnemonics/slip39/mnemonic.py | 59 ++++++++++------------ hdwallet/seeds/slip39.py | 2 - requirements/dev.txt | 1 + tests/cli/test_cli_seed.py | 1 - tests/data/raw/languages.txt | 10 ++-- 15 files changed, 54 insertions(+), 63 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 6ce6ad90..9431c6ee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,6 @@ include LICENSE include README.md include requirements.txt -include requirements/slip39.txt include requirements/cli.txt include requirements/tests.txt include requirements/docs.txt diff --git a/Makefile b/Makefile index 9b65c768..21452587 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ export NIX_OPTS ?= help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: help wheel install venv Makefile FORCE +.PHONY: help wheel install test analyze venv Makefile FORCE wheel: $(WHEEL) @@ -55,6 +55,11 @@ unit-%: test: $(PYTEST) $(PYTEST_OPTS) tests +analyze: + $(PYTHON) -m flake8 --color never -j 1 --max-line-length=250 \ + --ignore=W503,W504,E201,E202,E223,E226 \ + hdwallet + # # Nix and VirtualEnv build, install and activate # diff --git a/hdwallet/cli/generate/seed.py b/hdwallet/cli/generate/seed.py index d00d7cdb..f1b2eabc 100644 --- a/hdwallet/cli/generate/seed.py +++ b/hdwallet/cli/generate/seed.py @@ -30,12 +30,12 @@ def generate_seed(**kwargs) -> None: if not MNEMONICS.mnemonic(name="Electrum-V2").is_valid( mnemonic=kwargs.get("mnemonic"), mnemonic_type=kwargs.get("mnemonic_type") ): - click.echo(click.style(f"Invalid Electrum-V2 mnemonic"), err=True) + click.echo(click.style("Invalid Electrum-V2 mnemonic"), err=True) sys.exit() elif kwargs.get("client") != SLIP39Seed.name(): # SLIP39 supports any 128-, 256- or 512-bit Mnemonic mnemonic_name: str = "BIP39" if kwargs.get("client") == CardanoSeed.name() else kwargs.get("client") if not MNEMONICS.mnemonic(name=mnemonic_name).is_valid(mnemonic=kwargs.get("mnemonic")): - click.echo(click.style(f"Invalid {mnemonic_name} mnemonic {kwargs.get('mnemonic')!r}"), err=True) + click.echo(click.style(f"Invalid {mnemonic_name} mnemonic"), err=True) sys.exit() if kwargs.get("client") == BIP39Seed.name(): diff --git a/hdwallet/entropies/ientropy.py b/hdwallet/entropies/ientropy.py index 00c11e0f..00a15ba3 100644 --- a/hdwallet/entropies/ientropy.py +++ b/hdwallet/entropies/ientropy.py @@ -18,7 +18,7 @@ class IEntropy: - + _entropy: str _strength: int @@ -148,4 +148,4 @@ def are_entropy_bits_enough(self, entropy: Union[bytes, int]) -> bool: """ if self.name() != "Electrum-V2": - raise NotImplemented + raise NotImplementedError diff --git a/hdwallet/mnemonics/__init__.py b/hdwallet/mnemonics/__init__.py index 8d2c7b00..729440e7 100644 --- a/hdwallet/mnemonics/__init__.py +++ b/hdwallet/mnemonics/__init__.py @@ -9,20 +9,20 @@ ) from ..exceptions import MnemonicError -from .algorand import ( +from .algorand import ( # noqa: F401 AlgorandMnemonic, ALGORAND_MNEMONIC_WORDS, ALGORAND_MNEMONIC_LANGUAGES ) -from .bip39 import ( +from .bip39 import ( # noqa: F401 BIP39Mnemonic, BIP39_MNEMONIC_WORDS, BIP39_MNEMONIC_LANGUAGES ) -from .slip39 import ( +from .slip39 import ( # noqa: F401 SLIP39Mnemonic, SLIP39_MNEMONIC_WORDS, SLIP39_MNEMONIC_LANGUAGES ) -from .electrum import ( +from .electrum import ( # noqa: F401 ElectrumV1Mnemonic, ELECTRUM_V1_MNEMONIC_WORDS, ELECTRUM_V1_MNEMONIC_LANGUAGES, ElectrumV2Mnemonic, ELECTRUM_V2_MNEMONIC_WORDS, ELECTRUM_V2_MNEMONIC_LANGUAGES, ELECTRUM_V2_MNEMONIC_TYPES ) -from .monero import ( +from .monero import ( # noqa: F401 MoneroMnemonic, MONERO_MNEMONIC_WORDS, MONERO_MNEMONIC_LANGUAGES ) from .imnemonic import IMnemonic @@ -44,6 +44,8 @@ class MNEMONICS: +--------------+------------------------------------------------------------------------+ | BIP39 | :class:`hdwallet.mnemonics.bip39.mnemonic.BIP39Mnemonic` | +--------------+------------------------------------------------------------------------+ + | SLIP39 | :class:`hdwallet.mnemonics.slip39.mnemonic.SLIP39Mnemonic` | + +--------------+------------------------------------------------------------------------+ | Electrum-V1 | :class:`hdwallet.mnemonics.electrum.v1.mnemonic.ElectrumV1Mnemonic` | +--------------+------------------------------------------------------------------------+ | Electrum-V2 | :class:`hdwallet.mnemonics.electrum.v2.mnemonic.ElectrumV2Mnemonic` | @@ -55,10 +57,10 @@ class MNEMONICS: dictionary: Dict[str, Type[IMnemonic]] = { AlgorandMnemonic.name(): AlgorandMnemonic, BIP39Mnemonic.name(): BIP39Mnemonic, + SLIP39Mnemonic.name(): SLIP39Mnemonic, ElectrumV1Mnemonic.name(): ElectrumV1Mnemonic, ElectrumV2Mnemonic.name(): ElectrumV2Mnemonic, MoneroMnemonic.name(): MoneroMnemonic, - SLIP39Mnemonic.name(): SLIP39Mnemonic, } @classmethod diff --git a/hdwallet/mnemonics/algorand/mnemonic.py b/hdwallet/mnemonics/algorand/mnemonic.py index bfef6fa8..f58a5f97 100644 --- a/hdwallet/mnemonics/algorand/mnemonic.py +++ b/hdwallet/mnemonics/algorand/mnemonic.py @@ -8,14 +8,12 @@ Union, Dict, List, Optional ) -import unicodedata - from ...entropies import ( IEntropy, AlgorandEntropy, ALGORAND_ENTROPY_STRENGTHS ) from ...crypto import sha512_256 from ...exceptions import ( - Error, EntropyError, MnemonicError, ChecksumError + EntropyError, MnemonicError, ChecksumError ) from ...utils import ( get_bytes, bytes_to_string, convert_bits diff --git a/hdwallet/mnemonics/electrum/v1/mnemonic.py b/hdwallet/mnemonics/electrum/v1/mnemonic.py index ed84665b..63f75b1c 100644 --- a/hdwallet/mnemonics/electrum/v1/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v1/mnemonic.py @@ -8,8 +8,6 @@ Dict, List, Union, Optional ) -import unicodedata - from ....entropies import ( IEntropy, ElectrumV1Entropy, ELECTRUM_V1_ENTROPY_STRENGTHS ) diff --git a/hdwallet/mnemonics/electrum/v2/mnemonic.py b/hdwallet/mnemonics/electrum/v2/mnemonic.py index 1aa69d7c..c7e360ea 100644 --- a/hdwallet/mnemonics/electrum/v2/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v2/mnemonic.py @@ -8,8 +8,6 @@ Dict, List, Union, Optional ) -import unicodedata - from ....entropies import ( IEntropy, ElectrumV2Entropy, ELECTRUM_V2_ENTROPY_STRENGTHS ) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 8bc46fcc..cb63ba5a 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -72,7 +72,7 @@ def mnemonic_type(self) -> str: :rtype: str """ - raise NotImplemented + raise NotImplementedError def language(self) -> str: """ @@ -245,6 +245,6 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: if isinstance(mnemonic, str): if ( len(mnemonic.strip()) * 4 in cls.words_to_entropy_strength.values() and all(c in string.hexdigits for c in mnemonic.strip())): - mnemonic: str = cls.from_entropy(mnemonic, language="english") + mnemonic: str = cls.from_entropy(mnemonic, language="english") mnemonic: list = mnemonic.strip().split() return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) diff --git a/hdwallet/mnemonics/monero/mnemonic.py b/hdwallet/mnemonics/monero/mnemonic.py index 2576664e..8ef17b5f 100644 --- a/hdwallet/mnemonics/monero/mnemonic.py +++ b/hdwallet/mnemonics/monero/mnemonic.py @@ -8,8 +8,6 @@ Union, Dict, List ) -import unicodedata - from ...entropies import ( IEntropy, MoneroEntropy, MONERO_ENTROPY_STRENGTHS ) diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index 16ce2fd4..ad735541 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -6,14 +6,14 @@ import re from typing import ( - Union, Dict, Iterable, List, Optional, Tuple + Union, Dict, Iterable, List, Optional, Sequence, Tuple ) from ...entropies import ( - IEntropy, SLIP39Entropy, SLIP39_ENTROPY_STRENGTHS + ENTROPIES, IEntropy, SLIP39Entropy, SLIP39_ENTROPY_STRENGTHS ) from ...exceptions import ( - Error, EntropyError, MnemonicError, ChecksumError + EntropyError, MnemonicError ) from ...utils import ( get_bytes, @@ -38,7 +38,7 @@ class SLIP39_MNEMONIC_LANGUAGES: ENGLISH: str = "english" -def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tuple[int,int]]: +def group_parser( group_spec, size_default: Optional[int] = None) -> Tuple[str, Tuple[int, int]]: """Parse a SLIP-39 group specification; a name up to the first digit, ( or /, then a threshold/count spec: @@ -79,7 +79,8 @@ def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tu if size < 1 or require > size or ( require == 1 and size > 1 ): raise ValueError( f"Impossible group specification from {group_spec!r} w/ default size {size_default!r}: {name,(require,size)!r}" ) - return name,(require,size) + return (name, (require, size)) + group_parser.REQUIRED_RATIO = 1/2 group_parser.RE = re.compile( # noqa E305 @@ -106,7 +107,7 @@ def group_parser( group_spec, size_default: Optional[int]=None ) -> Tuple[str,Tu """, re.VERBOSE ) -def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[str,int],Tuple[int,int]]]: +def language_parser(language: str) -> Dict[Tuple[str, Tuple[int, int]], Dict[Union[str, int], Tuple[int, int]]]: """ Parse a SLIP-39 language dialect specification. @@ -140,28 +141,28 @@ def language_parser(language: str) -> Dict[Tuple[str,Tuple[int,int]],Dict[Union[ if not s_match and language.strip(): raise ValueError( f"Invalid SLIP-39 specification: {language!r}" ) - groups = s_match and s_match.group("groups") or "" groups_list = groups.strip().split(",") secret = s_match and s_match.group("secret") or "" s_size_default = len(groups_list) if groups_list else None - s_name,(s_thresh,s_size) = group_parser(secret, size_default=s_size_default) + s_name, (s_thresh, s_size) = group_parser(secret, size_default=s_size_default) groups_list += [''] * (s_size - len(groups_list)) # default any missing group specs - g_names,g_sizes = [],[] + g_names, g_sizes = [], [] for group in groups_list: # Default size inferred from Fibonacci sequence of mnemonics required by default size_default = None if len(g_sizes) < 2 else min( MAX_SHARE_COUNT, 2 * ( g_sizes[-1][0] + g_sizes[-2][0] ) ) - g_name,g_dims = group_parser(group, size_default=size_default) + g_name, g_dims = group_parser(group, size_default=size_default) if not g_name: g_name = len(g_sizes) g_names.append(g_name) g_sizes.append(g_dims) - return { (s_name.strip(),(s_thresh,s_size)): dict(zip(g_names,g_sizes)) } + return { (s_name.strip(), (s_thresh, s_size)): dict(zip(g_names, g_sizes)) } + language_parser.REQUIRED_RATIO = 1/2 language_parser.RE = re.compile( @@ -233,11 +234,10 @@ class SLIP39Mnemonic(IMnemonic): SLIP39_MNEMONIC_LANGUAGES.ENGLISH: "slip39/wordlist/english.txt", } - def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: super().__init__(mnemonic, **kwargs) # We know that normalize has already validated _mnemonic's length - self._words, = filter(lambda l: len(self._mnemonic) % l == 0, self.words_list) + self._words, = filter(lambda w: len(self._mnemonic) % w == 0, self.words_list) @classmethod def name(cls) -> str: @@ -324,16 +324,14 @@ def from_entropy(cls, entropy: Union[str, bytes, IEntropy], language: str, **kwa "Invalid entropy instance", expected=[str, bytes,]+list(ENTROPIES.dictionary.values()), got=type(entropy) ) - @classmethod def is_valid_language(cls, language: str) -> bool: try: language_parser(language) return True - except Exception as exc: + except Exception: return False - @classmethod def encode( cls, @@ -373,20 +371,19 @@ def encode( "Wrong entropy strength", expected=SLIP39Entropy.strengths, got=(len(entropy) * 8) ) - ((s_name,(s_thresh,s_size)),groups), = language_parser(language).items() + ((s_name, (s_thresh, s_size)), groups), = language_parser(language).items() assert s_size == len(groups) group_mnemonics: Sequence[Sequence[str]] = generate_mnemonics( - group_threshold = s_thresh, - groups = groups.values(), - master_secret = entropy, - passphrase = passphrase.encode('UTF-8'), - extendable = extendable, - iteration_exponent = iteration_exponent, + group_threshold=s_thresh, + groups=groups.values(), + master_secret=entropy, + passphrase=passphrase.encode('UTF-8'), + extendable=extendable, + iteration_exponent=iteration_exponent, ) return "\n".join(sum(group_mnemonics, [])) - @classmethod def decode( cls, mnemonic: str, passphrase: str = "", @@ -432,8 +429,7 @@ def decode( entropy = bytes_to_string(recovery.recover(passphrase.encode('UTF-8'))) return entropy except Exception as exc: - raise MnemonicError(f"Failed to recover SLIP-39 Mnemonics", detail=exc) from exc - + raise MnemonicError("Failed to recover SLIP-39 Mnemonics", detail=exc) from exc NORMALIZE = re.compile( r""" @@ -450,7 +446,6 @@ def decode( $ """, re.VERBOSE ) - @classmethod def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: """Filter the supplied lines of mnemonics, rejecting groups of mnemonics not evenly divisible by @@ -479,12 +474,12 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: if isinstance( mnemonic, str ): mnemonic_list: List[str] = [] - for line_no,m in enumerate( map( cls.NORMALIZE.match, mnemonic.split("\n"))): + for line_no, m in enumerate( map( cls.NORMALIZE.match, mnemonic.split("\n"))): if not m: errors.append( f"@L{line_no+1}; unrecognized mnemonic ignored" ) continue - pref,mnem = m.groups() + pref, mnem = m.groups() if not mnem: # Blank lines or lines without Mnemonic skipped continue mnem = super().normalize(mnem) @@ -495,12 +490,12 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: else: mnemonic_list: List[str] = mnemonic - word_lengths = list(filter(lambda l: len(mnemonic_list) % l == 0, cls.words_list)) + word_lengths = list(filter(lambda w: len(mnemonic_list) % w == 0, cls.words_list)) if not word_lengths: - errors.append( f"Mnemonics not a multiple of valid length, or a single hex entropy value" ) + errors.append( "Mnemonics not a multiple of valid length, or a single hex entropy value" ) if errors: raise MnemonicError( - f"Invalid SLIP39 Mnemonics", + "Invalid SLIP39 Mnemonics", expected=f"multiple of {', '.join(map(str, cls.words_list))}", got=f"{len(mnemonic_list)} total words", detail="; ".join(errors), diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index 66500d98..1038e283 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -8,8 +8,6 @@ Optional, Union ) -import unicodedata - from ..exceptions import EntropyError from ..mnemonics import IMnemonic from ..mnemonics.bip39 import BIP39Mnemonic diff --git a/requirements/dev.txt b/requirements/dev.txt index 02ac8952..fe58f629 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,3 +1,4 @@ build setuptools wheel +flake8 diff --git a/tests/cli/test_cli_seed.py b/tests/cli/test_cli_seed.py index de8bc8e5..de262e64 100644 --- a/tests/cli/test_cli_seed.py +++ b/tests/cli/test_cli_seed.py @@ -80,7 +80,6 @@ def test_cli_seed(data, cli_tester): cli = cli_tester.invoke( cli_main, cli_args ) - print( f"cli output: {cli.output}" ) output = json.loads(cli.output) assert output["client"] == client diff --git a/tests/data/raw/languages.txt b/tests/data/raw/languages.txt index 4497d572..5c41671a 100644 --- a/tests/data/raw/languages.txt +++ b/tests/data/raw/languages.txt @@ -19,6 +19,11 @@ Spanish Turkish +SLIP39 Languages +------------------ +English + + Electrum-V1 Languages ----------------------- English @@ -44,8 +49,3 @@ Japanese Portuguese Russian Spanish - - -SLIP39 Languages ------------------- -English From bcd1cda5abf85a614a6cd2eefbc02d57eb54f13b Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Thu, 21 Aug 2025 18:53:34 -0600 Subject: [PATCH 15/38] Implement SLIP39Mnemonic.encode tabulate for human readable mnemonics --- hdwallet/mnemonics/slip39/mnemonic.py | 145 +++++++-- .../mnemonics/test_mnemonics_slip39.py | 297 ++++++++++++++++++ 2 files changed, 423 insertions(+), 19 deletions(-) diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index ad735541..a4e73432 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -6,7 +6,7 @@ import re from typing import ( - Union, Dict, Iterable, List, Optional, Sequence, Tuple + Union, Dict, Iterable, List, Optional, Sequence, Collection, Tuple ) from ...entropies import ( @@ -25,6 +25,8 @@ from shamir_mnemonic.constants import MAX_SHARE_COUNT from shamir_mnemonic.recovery import RecoveryState, Share +from tabulate import tabulate + class SLIP39_MNEMONIC_WORDS: @@ -80,10 +82,8 @@ def group_parser( group_spec, size_default: Optional[int] = None) -> Tuple[str, raise ValueError( f"Impossible group specification from {group_spec!r} w/ default size {size_default!r}: {name,(require,size)!r}" ) return (name, (require, size)) - - -group_parser.REQUIRED_RATIO = 1/2 -group_parser.RE = re.compile( # noqa E305 +group_parser.REQUIRED_RATIO = 1/2 # noqa: E305 +group_parser.RE = re.compile( r""" ^ \s* @@ -162,9 +162,7 @@ def language_parser(language: str) -> Dict[Tuple[str, Tuple[int, int]], Dict[Uni g_sizes.append(g_dims) return { (s_name.strip(), (s_thresh, s_size)): dict(zip(g_names, g_sizes)) } - - -language_parser.REQUIRED_RATIO = 1/2 +language_parser.REQUIRED_RATIO = 1/2 # noqa: E305 language_parser.RE = re.compile( r""" ^ @@ -189,6 +187,96 @@ def language_parser(language: str) -> Dict[Tuple[str, Tuple[int, int]], Dict[Uni """, re.VERBOSE) +def ordinal( num ): + q, mod = divmod( num, 10 ) + suffix = q % 10 != 1 and ordinal.suffixes.get(mod) or "th" + return f"{num}{suffix}" +ordinal.suffixes = {1: "st", 2: "nd", 3: "rd"} # noqa: E305 + + +def tabulate_slip39( + groups: Dict[Union[str, int], Tuple[int, int]], + group_mnemonics: Sequence[Collection[str]], + columns=None, # default: columnize, but no wrapping +) -> str: + """Return SLIP-39 groups with group names/numbers, a separator, and tabulated mnemonics. + + Mnemonics exceeding 'columns' will be wrapped with no prefix except a continuation character. + + The default behavior (columns is falsey) is to NOT wrap the mnemonics (no columns limit). If + columns is True or 1 (truthy, but not a specific sensible column size), we'll use the + tabulate_slip39.default of 20. Otherwise, we'll use the specified specific columns. + + """ + if not columns: # False, None, 0 + limit = 0 + elif int(columns) > 1: # 2, ... + limit = int(columns) + else: # True, 1 + limit = tabulate_slip39.default + + def prefixed( groups, group_mnemonics ): + for g, ((name, (threshold, count)), mnemonics) in enumerate( zip( groups.items(), group_mnemonics )): + assert count == len( mnemonics ) + for o, mnem in enumerate( sorted( map( str.split, mnemonics ))): + siz = limit or len( mnem ) + end = len( mnem ) + rows = ( end + siz - 1 ) // siz + for r, col in enumerate( range( 0, end, siz )): + con = '' + if count == 1: # A 1/1 + if rows == 1: + sep = '━' # on 1 row + elif r == 0: + sep = '┭' # on multiple rows + con = '╎' + elif r+1 < rows: + sep = '├' + con = '╎' + else: + sep = '└' + elif rows == 1: # An N/M w/ full row mnemonics + if o == 0: # on 1 row, 1st mnemonic + sep = '┳' + con = '╏' + elif o+1 < count: + sep = '┣' + con = '╏' + else: + sep = '┗' + else: # An N/M, but multi-row mnemonics + if o == 0 and r == 0: # on 1st row, 1st mnemonic + sep = '┳' + con = '╎' + elif r == 0: # on 1st row, any mnemonic + sep = '┣' + con = '╎' + elif r+1 < rows: # on mid row, any mnemonic + sep = '├' + con = '╎' + elif o+1 < count: # on last row, but not last mneonic + sep = '└' + con = '╏' + else: + sep = '└' # on last row of last mnemonic + + # Output the prefix and separator + mnemonics + yield [ + f"{name} {threshold}/{count} " if o == 0 and col == 0 else "" + ] + [ + ordinal(o+1) if col == 0 else "" + ] + [ + sep + ] + mnem[col:col+siz] + + # And if not the last group and mnemonic, but a last row; Add a blank or continuation row + if r+1 == rows and not (g+1 == len(groups) and o+1 == count): + yield ["", "", con] if con else [None] + + return tabulate( prefixed( groups, group_mnemonics ), tablefmt='plain' ) +tabulate_slip39.default = 20 # noqa: E305 + + class SLIP39Mnemonic(IMnemonic): """ Implements the SLIP39 standard, allowing the creation of mnemonic phrases for @@ -340,7 +428,7 @@ def encode( passphrase: str = "", extendable: bool = True, iteration_exponent: int = 1, - tabulate: bool = False, + tabulate: bool = False, # False disables; any other value causes prefixing/columnization ) -> str: """ Encodes entropy into a mnemonic phrase. @@ -373,7 +461,7 @@ def encode( ((s_name, (s_thresh, s_size)), groups), = language_parser(language).items() assert s_size == len(groups) - group_mnemonics: Sequence[Sequence[str]] = generate_mnemonics( + group_mnemonics: Sequence[Collection[str]] = generate_mnemonics( group_threshold=s_thresh, groups=groups.values(), master_secret=entropy, @@ -382,6 +470,8 @@ def encode( iteration_exponent=iteration_exponent, ) + if tabulate is not False: # None/0 imply no column limits + return tabulate_slip39(groups, group_mnemonics, columns=tabulate) return "\n".join(sum(group_mnemonics, [])) @classmethod @@ -436,7 +526,8 @@ def decode( ^ \s* ( - [\w\d\s]* [^\w\d\s] # Group 1 { <-- a single non-word/space/digit separator allowed + [ \w\d\s()/]* # Group(1/1) 1st { <-- a single non-word/space/digit separator allowed + [^\w\d\s()/] # Any symbol not comprising a valid group_parser language symbol )? \s* ( @@ -458,8 +549,10 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: symbol, before any number of Mnemonic word/space symbols: Group 1 { word word ... + Group 2 ╭ word word ... ╰ word word ... + Group 3 ┌ word word ... ├ word word ... └ word word ... @@ -469,27 +562,41 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: | single non-word/digit/space + + Since multi-row mnemonics are possible, we cannot always confirm that the accumulated + mnemonic size is valid after every mnemonic row. We can certainly identify the end of a + mnemonic by a blank row (it doesn't make sense to allow a single Mnemonic to be split across + blank rows), or the end of input. + """ errors = [] if isinstance( mnemonic, str ): mnemonic_list: List[str] = [] - for line_no, m in enumerate( map( cls.NORMALIZE.match, mnemonic.split("\n"))): + for line_no, line in enumerate( mnemonic.split("\n")): + m = cls.NORMALIZE.match( line ) if not m: - errors.append( f"@L{line_no+1}; unrecognized mnemonic ignored" ) + errors.append( f"@L{line_no+1}: unrecognized mnemonic line" ) continue pref, mnem = m.groups() - if not mnem: # Blank lines or lines without Mnemonic skipped - continue - mnem = super().normalize(mnem) - if len(mnem) in cls.words_list: - mnemonic_list.extend(mnem) + if mnem: + mnemonic_list.extend( super().normalize( mnem )) else: - errors.append( f"@L{line_no+1}; odd {len(mnem)}-word mnemonic ignored" ) + # Blank lines or lines without Mnemonic skipped. But they do indicate the end + # of a mnemonic! At this moment, the total accumulated Mnemonic(s) must be + # valid -- or the last one must have been bad. + word_lengths = list(filter(lambda w: len(mnemonic_list) % w == 0, cls.words_list)) + if not word_lengths: + errors.append( f"@L{line_no}: odd length mnemonic encountered" ) + break else: mnemonic_list: List[str] = mnemonic + # Regardless of the Mnemonic source; the total number of words must be a valid multiple of + # the SLIP-39 mnemonic word lengths. Fortunately, the LCM of (20, 33 and 59) is 38940, so + # we cannot encounter a sufficient body of mnemonics to ever run into an uncertain SLIP-39 + # Mnemonic length in words. word_lengths = list(filter(lambda w: len(mnemonic_list) % w == 0, cls.words_list)) if not word_lengths: errors.append( "Mnemonics not a multiple of valid length, or a single hex entropy value" ) diff --git a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py index 7cf3d07a..fe651c05 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py @@ -1,8 +1,12 @@ +import contextlib +import pytest + from hdwallet.exceptions import MnemonicError from hdwallet.mnemonics.slip39.mnemonic import ( SLIP39Mnemonic, language_parser, group_parser ) +import shamir_mnemonic def test_slip39_language(): @@ -126,3 +130,296 @@ def test_slip39_mnemonics(): pytest.fail( f"Subset size {subset_size}: Unexpected error type {type(e)}: {e}" ) + + +class substitute( contextlib.ContextDecorator ): + """The SLIP-39 standard includes random data in portions of the as share. Replace the random + function during testing to get determinism in resultant nmenomics. + + """ + def __init__( self, thing, attribute, value ): + self.thing = thing + self.attribute = attribute + self.value = value + self.saved = None + + def __enter__( self ): + self.saved = getattr( self.thing, self.attribute ) + setattr( self.thing, self.attribute, self.value ) + + def __exit__( self, *exc ): + setattr( self.thing, self.attribute, self.saved ) + + +@substitute( shamir_mnemonic.shamir, 'RANDOM_BYTES', lambda n: b'\0' * n ) +def test_slip39_tabulate(): + + entropy_128 = "ff"*(128//8) + entropy_256 = "ff"*(256//8) + entropy_512 = "ff"*(512//8) + + family = "Perry Kundert [ One 1/1, Two 1/1, Fam 2/4, Frens 3/6 ]" + assert SLIP39Mnemonic.encode(entropy=entropy_128, language=family, tabulate=None) == """\ +One 1/1 1st ━ academic agency acrobat romp course prune deadline umbrella darkness salt bishop impact vanish squeeze moment segment privacy bolt making enjoy + +Two 1/1 1st ━ academic agency beard romp downtown inmate hamster counter rainbow grocery veteran decorate describe bedroom disease suitable peasant editor welfare spider + +Fam 2/4 1st ┳ academic agency ceramic roster crystal critical forbid sled building glad legs angry enlarge ting ranked round solution legend ending lips + ╏ + 2nd ┣ academic agency ceramic scared drink verdict funding dragon activity verify fawn yoga devote perfect jacket database picture genius process pipeline + ╏ + 3rd ┣ academic agency ceramic shadow avoid leaf fantasy midst crush fraction cricket taxi velvet gasoline daughter august rhythm excuse wrist increase + ╏ + 4th ┗ academic agency ceramic sister capital flexible favorite grownup diminish sidewalk yelp blanket market class testify temple silent prevent born galaxy + +Frens 3/6 1st ┳ academic agency decision round academic academic academic academic academic academic academic academic academic academic academic academic academic phrase trust golden + ╏ + 2nd ┣ academic agency decision scatter desert wisdom birthday fatigue lecture detailed destroy realize recover lilac genre venture jacket mountain blessing pulse + ╏ + 3rd ┣ academic agency decision shaft birthday debut benefit shame market devote angel finger traveler analysis pipeline extra funding lawsuit editor guilt + ╏ + 4th ┣ academic agency decision skin category skin alpha observe artwork advance earth thank fact material sheriff peaceful club evoke robin revenue + ╏ + 5th ┣ academic agency decision snake anxiety acrobat inform home patrol alpha erode steady cultural juice emerald reject flash license royal plunge + ╏ + 6th ┗ academic agency decision spider earth woman gasoline dryer civil deliver laser hospital mountain wrist clinic evidence database public dwarf lawsuit""" + + assert SLIP39Mnemonic.encode(entropy=entropy_512, language=family, tabulate=None) == """\ +One 1/1 1st ━ academic agency acrobat romp acid airport meaning source sympathy junction symbolic lyrics install enjoy remind trend blind vampire type idle kind facility venture image inherit talent burning woman devote guest prevent news rich type unkind clay venture raisin oasis crisis firefly change index hanger belong true floral fawn busy fridge invasion member hesitate railroad campus edge ocean woman spill + +Two 1/1 1st ━ academic agency beard romp acid ruler execute bishop tolerate paid likely decent lips carbon exchange saver diminish year credit pacific deliver treat pacific aviation email river paper being deadline hawk gasoline nylon favorite duration spine lungs mixed stadium briefing prisoner fragment submit material fatal ultimate mixture sprinkle genuine educate sympathy anatomy visual carbon station exceed enemy mayor custody lyrics + +Fam 2/4 1st ┳ academic agency ceramic roster academic lyrics envelope tendency flexible careful shelter often plunge headset educate freshman isolate flea receiver hunting training tricycle legal snapshot rainbow pencil enforce priority spine hesitate civil scandal makeup privacy vitamins platform inherit sheriff relate evil breathe lilac vitamins theater render patrol airport vitamins clogs hour standard sugar exceed shadow laundry involve ticket public cargo + ╏ + 2nd ┣ academic agency ceramic scared academic western unknown daughter valid satisfy remember toxic chubby various become pile craft taste group listen amazing phantom rescue sugar patrol require discuss amazing software guitar race observe window medical sister fatal else species mule hesitate formal flash steady isolate express repair fangs expand likely fumes evoke champion screw space imply dive yoga ordinary rebound + ╏ + 3rd ┣ academic agency ceramic shadow academic harvest rebuild knit beard pickup corner clogs payroll detailed tendency ultimate sugar earth pharmacy wits deploy capacity fiction aide observe very breathe genre swing ancient arcade juice guest leaves mixture superior born wavy endorse lying omit coding angry bishop evening yelp pitch satoshi impact avoid username practice easy wavy scout credit emperor physics crazy + ╏ + 4th ┗ academic agency ceramic sister academic browser axle quantity recover junk float forbid criminal premium puny boundary mama regret intimate body spark false hour aunt march typical grumpy scene strategy award observe clinic bucket parcel pink charity clothes that hand platform syndrome video clay medical rhythm tracks writing junior spew dynamic health eyebrow silent theater shadow grasp garbage mandate length + +Frens 3/6 1st ┳ academic agency decision round academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic fragment receiver provide + ╏ + 2nd ┣ academic agency decision scatter academic wealthy health losing moisture display damage scout junk roster percent society income lying bolt again privacy visual firm infant coal lawsuit scout eraser campus alpha force fragment obtain very acquire firefly eyebrow judicial primary pecan entrance counter snake parking anxiety general strategy manual wireless provide timber level warn join frost episode primary percent maximum + ╏ + 3rd ┣ academic agency decision shaft acquire likely unfair grill course news fake bulge trip drift treat news manual corner game depart item devote writing taste cleanup leaves taste jewelry speak fumes darkness spider execute canyon legs unfair sniff tackle actress laden kernel rhythm smear ranked regular describe cause bike snapshot scandal sniff dress aspect task kidney wrote junction pistol suitable + ╏ + 4th ┣ academic agency decision skin acquire junction lobe teammate require pajamas laser talent mild wits exclude entrance yield pants epidemic dilemma sprinkle roster pink prayer admit yelp building depend slim floral inherit luxury spirit unhappy lecture resident legend picture pregnant strategy depict museum carpet biology quarter filter webcam paid crisis industry desktop rhyme vitamins pharmacy charity receiver mama research ticket + ╏ + 5th ┣ academic agency decision snake acne intimate empty treat agency ceiling destroy industry river machine editor standard prospect alarm spider security aquatic satisfy rapids inform very threaten withdraw market desktop furl devote squeeze anxiety lamp patrol oasis grill regret artwork downtown invasion shadow grant pecan tidy gray credit amazing expand secret trip mixed perfect remind best lobe adult airport penalty + ╏ + 6th ┗ academic agency decision spider acne memory daisy humidity nail bucket burden puny scandal epidemic tidy alarm satoshi medal safari saver party detailed taxi acid spine obtain dive seafood cradle focus heat makeup method mason patent sister dictate rumor pajamas package early teammate race ajar unhappy agency very lips railroad invasion avoid away frost romp exotic smear vegan bolt nylon""" + + assert SLIP39Mnemonic.encode(entropy=entropy_512, language=family, tabulate=True) == """\ +One 1/1 1st ┭ academic agency acrobat romp acid airport meaning source sympathy junction symbolic lyrics install enjoy remind trend blind vampire type idle + ├ kind facility venture image inherit talent burning woman devote guest prevent news rich type unkind clay venture raisin oasis crisis + └ firefly change index hanger belong true floral fawn busy fridge invasion member hesitate railroad campus edge ocean woman spill + +Two 1/1 1st ┭ academic agency beard romp acid ruler execute bishop tolerate paid likely decent lips carbon exchange saver diminish year credit pacific + ├ deliver treat pacific aviation email river paper being deadline hawk gasoline nylon favorite duration spine lungs mixed stadium briefing prisoner + └ fragment submit material fatal ultimate mixture sprinkle genuine educate sympathy anatomy visual carbon station exceed enemy mayor custody lyrics + +Fam 2/4 1st ┳ academic agency ceramic roster academic lyrics envelope tendency flexible careful shelter often plunge headset educate freshman isolate flea receiver hunting + ├ training tricycle legal snapshot rainbow pencil enforce priority spine hesitate civil scandal makeup privacy vitamins platform inherit sheriff relate evil + └ breathe lilac vitamins theater render patrol airport vitamins clogs hour standard sugar exceed shadow laundry involve ticket public cargo + ╏ + 2nd ┣ academic agency ceramic scared academic western unknown daughter valid satisfy remember toxic chubby various become pile craft taste group listen + ├ amazing phantom rescue sugar patrol require discuss amazing software guitar race observe window medical sister fatal else species mule hesitate + └ formal flash steady isolate express repair fangs expand likely fumes evoke champion screw space imply dive yoga ordinary rebound + ╏ + 3rd ┣ academic agency ceramic shadow academic harvest rebuild knit beard pickup corner clogs payroll detailed tendency ultimate sugar earth pharmacy wits + ├ deploy capacity fiction aide observe very breathe genre swing ancient arcade juice guest leaves mixture superior born wavy endorse lying + └ omit coding angry bishop evening yelp pitch satoshi impact avoid username practice easy wavy scout credit emperor physics crazy + ╏ + 4th ┣ academic agency ceramic sister academic browser axle quantity recover junk float forbid criminal premium puny boundary mama regret intimate body + ├ spark false hour aunt march typical grumpy scene strategy award observe clinic bucket parcel pink charity clothes that hand platform + └ syndrome video clay medical rhythm tracks writing junior spew dynamic health eyebrow silent theater shadow grasp garbage mandate length + +Frens 3/6 1st ┳ academic agency decision round academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic + ├ academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic + └ academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic fragment receiver provide + ╏ + 2nd ┣ academic agency decision scatter academic wealthy health losing moisture display damage scout junk roster percent society income lying bolt again + ├ privacy visual firm infant coal lawsuit scout eraser campus alpha force fragment obtain very acquire firefly eyebrow judicial primary pecan + └ entrance counter snake parking anxiety general strategy manual wireless provide timber level warn join frost episode primary percent maximum + ╏ + 3rd ┣ academic agency decision shaft acquire likely unfair grill course news fake bulge trip drift treat news manual corner game depart + ├ item devote writing taste cleanup leaves taste jewelry speak fumes darkness spider execute canyon legs unfair sniff tackle actress laden + └ kernel rhythm smear ranked regular describe cause bike snapshot scandal sniff dress aspect task kidney wrote junction pistol suitable + ╏ + 4th ┣ academic agency decision skin acquire junction lobe teammate require pajamas laser talent mild wits exclude entrance yield pants epidemic dilemma + ├ sprinkle roster pink prayer admit yelp building depend slim floral inherit luxury spirit unhappy lecture resident legend picture pregnant strategy + └ depict museum carpet biology quarter filter webcam paid crisis industry desktop rhyme vitamins pharmacy charity receiver mama research ticket + ╏ + 5th ┣ academic agency decision snake acne intimate empty treat agency ceiling destroy industry river machine editor standard prospect alarm spider security + ├ aquatic satisfy rapids inform very threaten withdraw market desktop furl devote squeeze anxiety lamp patrol oasis grill regret artwork downtown + └ invasion shadow grant pecan tidy gray credit amazing expand secret trip mixed perfect remind best lobe adult airport penalty + ╏ + 6th ┣ academic agency decision spider acne memory daisy humidity nail bucket burden puny scandal epidemic tidy alarm satoshi medal safari saver + ├ party detailed taxi acid spine obtain dive seafood cradle focus heat makeup method mason patent sister dictate rumor pajamas package + └ early teammate race ajar unhappy agency very lips railroad invasion avoid away frost romp exotic smear vegan bolt nylon""" + + mnemonics = SLIP39Mnemonic.encode(entropy=entropy_512, language=family, tabulate=10) + assert mnemonics == """\ +One 1/1 1st ┭ academic agency acrobat romp acid airport meaning source sympathy junction + ├ symbolic lyrics install enjoy remind trend blind vampire type idle + ├ kind facility venture image inherit talent burning woman devote guest + ├ prevent news rich type unkind clay venture raisin oasis crisis + ├ firefly change index hanger belong true floral fawn busy fridge + └ invasion member hesitate railroad campus edge ocean woman spill + +Two 1/1 1st ┭ academic agency beard romp acid ruler execute bishop tolerate paid + ├ likely decent lips carbon exchange saver diminish year credit pacific + ├ deliver treat pacific aviation email river paper being deadline hawk + ├ gasoline nylon favorite duration spine lungs mixed stadium briefing prisoner + ├ fragment submit material fatal ultimate mixture sprinkle genuine educate sympathy + └ anatomy visual carbon station exceed enemy mayor custody lyrics + +Fam 2/4 1st ┳ academic agency ceramic roster academic lyrics envelope tendency flexible careful + ├ shelter often plunge headset educate freshman isolate flea receiver hunting + ├ training tricycle legal snapshot rainbow pencil enforce priority spine hesitate + ├ civil scandal makeup privacy vitamins platform inherit sheriff relate evil + ├ breathe lilac vitamins theater render patrol airport vitamins clogs hour + └ standard sugar exceed shadow laundry involve ticket public cargo + ╏ + 2nd ┣ academic agency ceramic scared academic western unknown daughter valid satisfy + ├ remember toxic chubby various become pile craft taste group listen + ├ amazing phantom rescue sugar patrol require discuss amazing software guitar + ├ race observe window medical sister fatal else species mule hesitate + ├ formal flash steady isolate express repair fangs expand likely fumes + └ evoke champion screw space imply dive yoga ordinary rebound + ╏ + 3rd ┣ academic agency ceramic shadow academic harvest rebuild knit beard pickup + ├ corner clogs payroll detailed tendency ultimate sugar earth pharmacy wits + ├ deploy capacity fiction aide observe very breathe genre swing ancient + ├ arcade juice guest leaves mixture superior born wavy endorse lying + ├ omit coding angry bishop evening yelp pitch satoshi impact avoid + └ username practice easy wavy scout credit emperor physics crazy + ╏ + 4th ┣ academic agency ceramic sister academic browser axle quantity recover junk + ├ float forbid criminal premium puny boundary mama regret intimate body + ├ spark false hour aunt march typical grumpy scene strategy award + ├ observe clinic bucket parcel pink charity clothes that hand platform + ├ syndrome video clay medical rhythm tracks writing junior spew dynamic + └ health eyebrow silent theater shadow grasp garbage mandate length + +Frens 3/6 1st ┳ academic agency decision round academic academic academic academic academic academic + ├ academic academic academic academic academic academic academic academic academic academic + ├ academic academic academic academic academic academic academic academic academic academic + ├ academic academic academic academic academic academic academic academic academic academic + ├ academic academic academic academic academic academic academic academic academic academic + └ academic academic academic academic academic academic fragment receiver provide + ╏ + 2nd ┣ academic agency decision scatter academic wealthy health losing moisture display + ├ damage scout junk roster percent society income lying bolt again + ├ privacy visual firm infant coal lawsuit scout eraser campus alpha + ├ force fragment obtain very acquire firefly eyebrow judicial primary pecan + ├ entrance counter snake parking anxiety general strategy manual wireless provide + └ timber level warn join frost episode primary percent maximum + ╏ + 3rd ┣ academic agency decision shaft acquire likely unfair grill course news + ├ fake bulge trip drift treat news manual corner game depart + ├ item devote writing taste cleanup leaves taste jewelry speak fumes + ├ darkness spider execute canyon legs unfair sniff tackle actress laden + ├ kernel rhythm smear ranked regular describe cause bike snapshot scandal + └ sniff dress aspect task kidney wrote junction pistol suitable + ╏ + 4th ┣ academic agency decision skin acquire junction lobe teammate require pajamas + ├ laser talent mild wits exclude entrance yield pants epidemic dilemma + ├ sprinkle roster pink prayer admit yelp building depend slim floral + ├ inherit luxury spirit unhappy lecture resident legend picture pregnant strategy + ├ depict museum carpet biology quarter filter webcam paid crisis industry + └ desktop rhyme vitamins pharmacy charity receiver mama research ticket + ╏ + 5th ┣ academic agency decision snake acne intimate empty treat agency ceiling + ├ destroy industry river machine editor standard prospect alarm spider security + ├ aquatic satisfy rapids inform very threaten withdraw market desktop furl + ├ devote squeeze anxiety lamp patrol oasis grill regret artwork downtown + ├ invasion shadow grant pecan tidy gray credit amazing expand secret + └ trip mixed perfect remind best lobe adult airport penalty + ╏ + 6th ┣ academic agency decision spider acne memory daisy humidity nail bucket + ├ burden puny scandal epidemic tidy alarm satoshi medal safari saver + ├ party detailed taxi acid spine obtain dive seafood cradle focus + ├ heat makeup method mason patent sister dictate rumor pajamas package + ├ early teammate race ajar unhappy agency very lips railroad invasion + └ avoid away frost romp exotic smear vegan bolt nylon""" + + + # Now test recovery from the prefixed mnemonics. First, normalize should work, giving us a + # straight list of all Mnemonics, of a length divisible by a valid SLIP-39 Mnemonic word length; + # in this case 59 (for 512-bit secrets). + import json + normalized = SLIP39Mnemonic.normalize( mnemonics ) + normalized_json = json.dumps( + [ + " ".join(normalized[col:col+59]) + for col in range(0,len(normalized),59) + ], indent=4 + ) + #print( normalized_json ) + assert normalized_json == """[ + "academic agency acrobat romp acid airport meaning source sympathy junction symbolic lyrics install enjoy remind trend blind vampire type idle kind facility venture image inherit talent burning woman devote guest prevent news rich type unkind clay venture raisin oasis crisis firefly change index hanger belong true floral fawn busy fridge invasion member hesitate railroad campus edge ocean woman spill", + "academic agency beard romp acid ruler execute bishop tolerate paid likely decent lips carbon exchange saver diminish year credit pacific deliver treat pacific aviation email river paper being deadline hawk gasoline nylon favorite duration spine lungs mixed stadium briefing prisoner fragment submit material fatal ultimate mixture sprinkle genuine educate sympathy anatomy visual carbon station exceed enemy mayor custody lyrics", + "academic agency ceramic roster academic lyrics envelope tendency flexible careful shelter often plunge headset educate freshman isolate flea receiver hunting training tricycle legal snapshot rainbow pencil enforce priority spine hesitate civil scandal makeup privacy vitamins platform inherit sheriff relate evil breathe lilac vitamins theater render patrol airport vitamins clogs hour standard sugar exceed shadow laundry involve ticket public cargo", + "academic agency ceramic scared academic western unknown daughter valid satisfy remember toxic chubby various become pile craft taste group listen amazing phantom rescue sugar patrol require discuss amazing software guitar race observe window medical sister fatal else species mule hesitate formal flash steady isolate express repair fangs expand likely fumes evoke champion screw space imply dive yoga ordinary rebound", + "academic agency ceramic shadow academic harvest rebuild knit beard pickup corner clogs payroll detailed tendency ultimate sugar earth pharmacy wits deploy capacity fiction aide observe very breathe genre swing ancient arcade juice guest leaves mixture superior born wavy endorse lying omit coding angry bishop evening yelp pitch satoshi impact avoid username practice easy wavy scout credit emperor physics crazy", + "academic agency ceramic sister academic browser axle quantity recover junk float forbid criminal premium puny boundary mama regret intimate body spark false hour aunt march typical grumpy scene strategy award observe clinic bucket parcel pink charity clothes that hand platform syndrome video clay medical rhythm tracks writing junior spew dynamic health eyebrow silent theater shadow grasp garbage mandate length", + "academic agency decision round academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic fragment receiver provide", + "academic agency decision scatter academic wealthy health losing moisture display damage scout junk roster percent society income lying bolt again privacy visual firm infant coal lawsuit scout eraser campus alpha force fragment obtain very acquire firefly eyebrow judicial primary pecan entrance counter snake parking anxiety general strategy manual wireless provide timber level warn join frost episode primary percent maximum", + "academic agency decision shaft acquire likely unfair grill course news fake bulge trip drift treat news manual corner game depart item devote writing taste cleanup leaves taste jewelry speak fumes darkness spider execute canyon legs unfair sniff tackle actress laden kernel rhythm smear ranked regular describe cause bike snapshot scandal sniff dress aspect task kidney wrote junction pistol suitable", + "academic agency decision skin acquire junction lobe teammate require pajamas laser talent mild wits exclude entrance yield pants epidemic dilemma sprinkle roster pink prayer admit yelp building depend slim floral inherit luxury spirit unhappy lecture resident legend picture pregnant strategy depict museum carpet biology quarter filter webcam paid crisis industry desktop rhyme vitamins pharmacy charity receiver mama research ticket", + "academic agency decision snake acne intimate empty treat agency ceiling destroy industry river machine editor standard prospect alarm spider security aquatic satisfy rapids inform very threaten withdraw market desktop furl devote squeeze anxiety lamp patrol oasis grill regret artwork downtown invasion shadow grant pecan tidy gray credit amazing expand secret trip mixed perfect remind best lobe adult airport penalty", + "academic agency decision spider acne memory daisy humidity nail bucket burden puny scandal epidemic tidy alarm satoshi medal safari saver party detailed taxi acid spine obtain dive seafood cradle focus heat makeup method mason patent sister dictate rumor pajamas package early teammate race ajar unhappy agency very lips railroad invasion avoid away frost romp exotic smear vegan bolt nylon" +]""" + # So decode should simply work, ignoring all the Group specification language prefixes and + # separator/continuation symbols. + assert SLIP39Mnemonic.decode( mnemonics ) == entropy_512 + + # And invalid ones should note why they failed. First, a valid one: + assert SLIP39Mnemonic.decode( """\ +One 1/1 1st ┭ academic agency acrobat romp acid airport meaning source sympathy junction symbolic lyrics install enjoy remind trend blind vampire type idle + ├ kind facility venture image inherit talent burning woman devote guest prevent news rich type unkind clay venture raisin oasis crisis + └ firefly change index hanger belong true floral fawn busy fridge invasion member hesitate railroad campus edge ocean woman spill + +Two 1/1 1st ┭ academic agency beard romp acid ruler execute bishop tolerate paid likely decent lips carbon exchange saver diminish year credit pacific + ├ deliver treat pacific aviation email river paper being deadline hawk gasoline nylon favorite duration spine lungs mixed stadium briefing prisoner + └ fragment submit material fatal ultimate mixture sprinkle genuine educate sympathy anatomy visual carbon station exceed enemy mayor custody lyrics + """) == entropy_512 + + # Missing last word of 1st Mnemonic (on line 3): + with pytest.raises(MnemonicError, match="@L3: odd length mnemonic encountered"): + SLIP39Mnemonic.decode( """\ +One 1/1 1st ┭ academic agency acrobat romp acid airport meaning source sympathy junction symbolic lyrics install enjoy remind trend blind vampire type idle + ├ kind facility venture image inherit talent burning woman devote guest prevent news rich type unkind clay venture raisin oasis crisis + └ firefly change index hanger belong true floral fawn busy fridge invasion member hesitate railroad campus edge ocean woman + +Two 1/1 1st ┭ academic agency beard romp acid ruler execute bishop tolerate paid likely decent lips carbon exchange saver diminish year credit pacific + ├ deliver treat pacific aviation email river paper being deadline hawk gasoline nylon favorite duration spine lungs mixed stadium briefing prisoner + └ fragment submit material fatal ultimate mixture sprinkle genuine educate sympathy anatomy visual carbon station exceed enemy mayor custody lyrics + """) + + # Funky lines + with pytest.raises(MnemonicError, match="@L4: unrecognized mnemonic line"): + SLIP39Mnemonic.decode( """\ +One 1/1 1st ┭ academic agency acrobat romp acid airport meaning source sympathy junction symbolic lyrics install enjoy remind trend blind vampire type idle + ├ kind facility venture image inherit talent burning woman devote guest prevent news rich type unkind clay venture raisin oasis crisis + └ firefly change index hanger belong true floral fawn busy fridge invasion member hesitate railroad campus edge ocean woman spill +# we don't support comments so this Mnemonic will fail due to invalid symbols +Two 1/1 1st ┭ academic agency beard romp acid ruler execute bishop tolerate paid likely decent lips carbon exchange saver diminish year credit pacific + ├ deliver treat pacific aviation email river paper being deadline hawk gasoline nylon favorite duration spine lungs mixed stadium briefing prisoner + └ fragment submit material fatal ultimate mixture sprinkle genuine educate sympathy anatomy visual carbon station exceed enemy mayor custody lyrics + """) + + # Bad Mnemonic words + with pytest.raises(MnemonicError, match="Failed to recover SLIP-39 Mnemonics Invalid mnemonic word 'we'."): + SLIP39Mnemonic.decode( """\ +One 1/1 1st ┭ academic agency acrobat romp acid airport meaning source sympathy junction symbolic lyrics install enjoy remind trend blind vampire type idle + ├ kind facility venture image inherit talent burning woman devote guest prevent news rich type unkind clay venture raisin oasis crisis + └ firefly change index hanger belong true floral fawn busy fridge invasion member hesitate railroad campus edge ocean woman spill +# we do not support comments so this Mnemonic will fail due to bad mnemonic words even though it happens to be the right length +# we do not support comments so this Mnemonic will fail due to bad mnemonic words even though it happens to be the right length +# because we purposely expertly accidentally made this line eleven words long +Two 1/1 1st ┭ academic agency beard romp acid ruler execute bishop tolerate paid likely decent lips carbon exchange saver diminish year credit pacific + ├ deliver treat pacific aviation email river paper being deadline hawk gasoline nylon favorite duration spine lungs mixed stadium briefing prisoner + └ fragment submit material fatal ultimate mixture sprinkle genuine educate sympathy anatomy visual carbon station exceed enemy mayor custody lyrics + """) From 3da7a7210d932dfac1cae86aa6cd8d6afc9dfc45 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Thu, 21 Aug 2025 09:31:57 -0600 Subject: [PATCH 16/38] Fix remaining whitespace, etc. issues found by analyze target --- hdwallet/addresses/iaddress.py | 2 +- hdwallet/addresses/monero.py | 6 +++--- hdwallet/addresses/okt_chain.py | 2 +- hdwallet/addresses/p2pkh.py | 2 +- hdwallet/addresses/p2tr.py | 2 +- hdwallet/addresses/p2wpkh.py | 2 +- hdwallet/addresses/tron.py | 4 ++-- hdwallet/cli/__init__.py | 4 ++-- hdwallet/cli/dump.py | 4 ++-- hdwallet/cli/dumps.py | 5 +---- hdwallet/consts.py | 6 +++--- hdwallet/cryptocurrencies/asiacoin.py | 1 - hdwallet/cryptocurrencies/auroracoin.py | 1 - hdwallet/cryptocurrencies/bata.py | 2 +- hdwallet/cryptocurrencies/bitcloud.py | 2 +- hdwallet/cryptocurrencies/bitcoingold.py | 2 +- hdwallet/cryptocurrencies/clams.py | 1 - hdwallet/cryptocurrencies/compcoin.py | 1 - hdwallet/cryptocurrencies/defcoin.py | 2 +- hdwallet/cryptocurrencies/dogecoin.py | 2 +- hdwallet/cryptocurrencies/foxdcoin.py | 1 - hdwallet/cryptocurrencies/litecoinz.py | 2 +- hdwallet/cryptocurrencies/potcoin.py | 2 +- hdwallet/cryptocurrencies/rapids.py | 1 - hdwallet/cryptocurrencies/ravencoin.py | 2 +- hdwallet/cryptocurrencies/reddcoin.py | 1 - hdwallet/cryptocurrencies/rubycoin.py | 2 +- hdwallet/cryptocurrencies/scribe.py | 2 +- hdwallet/cryptocurrencies/vivo.py | 1 - hdwallet/cryptocurrencies/xinfin.py | 1 - hdwallet/derivations/__init__.py | 4 ++-- hdwallet/derivations/bip44.py | 4 ++-- hdwallet/derivations/cip1852.py | 4 ++-- hdwallet/derivations/custom.py | 2 +- hdwallet/derivations/hdw.py | 4 ++-- hdwallet/eccs/__init__.py | 11 ++++++----- hdwallet/eccs/kholaw/ed25519/public_key.py | 1 - hdwallet/eccs/slip10/__init__.py | 1 - hdwallet/eccs/slip10/nist256p1/private_key.py | 2 +- hdwallet/eccs/slip10/nist256p1/public_key.py | 2 +- hdwallet/eccs/slip10/secp256k1/__init__.py | 6 +++--- hdwallet/entropies/__init__.py | 10 +++++----- hdwallet/exceptions.py | 4 +++- hdwallet/hds/algorand.py | 6 +++--- hdwallet/hds/bip141.py | 2 +- hdwallet/hds/bip32.py | 10 +++++----- hdwallet/hds/bip86.py | 2 +- hdwallet/hds/cardano.py | 16 ++++++++-------- hdwallet/hds/electrum/v1.py | 6 +++--- hdwallet/hds/electrum/v2.py | 4 ++-- hdwallet/hds/ihd.py | 9 ++++----- hdwallet/hds/monero.py | 12 ++++++------ hdwallet/hdwallet.py | 2 +- hdwallet/libs/base58.py | 2 +- hdwallet/libs/ecc.py | 4 ++-- hdwallet/seeds/cardano.py | 3 ++- hdwallet/utils.py | 18 +++++++++--------- hdwallet/wif.py | 4 ++-- 58 files changed, 106 insertions(+), 117 deletions(-) diff --git a/hdwallet/addresses/iaddress.py b/hdwallet/addresses/iaddress.py index f5a085ba..ec7079b5 100644 --- a/hdwallet/addresses/iaddress.py +++ b/hdwallet/addresses/iaddress.py @@ -10,7 +10,7 @@ ABC, abstractmethod ) from typing import ( - Union, Optional + Union ) from ..eccs import IPublicKey diff --git a/hdwallet/addresses/monero.py b/hdwallet/addresses/monero.py index e14d7f09..fa2153c2 100644 --- a/hdwallet/addresses/monero.py +++ b/hdwallet/addresses/monero.py @@ -173,10 +173,10 @@ def decode( "Invalid length", expected=(expected_length + cls.payment_id_length), got=len(payload_without_prefix) - ) + ) from ex if payment_id is None or len(payment_id) != cls.payment_id_length: - raise Error("Invalid payment ID") + raise Error("Invalid payment ID") from ex payment_id_got_bytes = payload_without_prefix[-cls.payment_id_length:] if payment_id != payment_id_got_bytes: @@ -184,7 +184,7 @@ def decode( "Invalid payment ID", expected=bytes_to_string(payment_id_got_bytes), got=bytes_to_string(payment_id_got_bytes) - ) + ) from ex length: int = SLIP10Ed25519MoneroPublicKey.compressed_length() diff --git a/hdwallet/addresses/okt_chain.py b/hdwallet/addresses/okt_chain.py index 8a62118c..bb4f063b 100644 --- a/hdwallet/addresses/okt_chain.py +++ b/hdwallet/addresses/okt_chain.py @@ -69,7 +69,7 @@ def decode(cls, address: str, **kwargs: Any) -> str: :return: Decoded OKT-Chain address. :rtype: str """ - + return EthereumAddress.decode( EthereumAddress.address_prefix + bytes_to_string( bech32_decode(cls.hrp, address)[1] diff --git a/hdwallet/addresses/p2pkh.py b/hdwallet/addresses/p2pkh.py index c3944d77..04cad03e 100644 --- a/hdwallet/addresses/p2pkh.py +++ b/hdwallet/addresses/p2pkh.py @@ -24,7 +24,7 @@ class P2PKHAddress(IAddress): - + public_key_address_prefix: int = Bitcoin.NETWORKS.MAINNET.PUBLIC_KEY_ADDRESS_PREFIX alphabet: str = Bitcoin.PARAMS.ALPHABET diff --git a/hdwallet/addresses/p2tr.py b/hdwallet/addresses/p2tr.py index a7a75953..4145438c 100644 --- a/hdwallet/addresses/p2tr.py +++ b/hdwallet/addresses/p2tr.py @@ -23,7 +23,7 @@ class P2TRAddress(IAddress): - + hrp: str = Bitcoin.NETWORKS.MAINNET.HRP field_size: int = Bitcoin.PARAMS.FIELD_SIZE tap_tweak_sha256: bytes = get_bytes(Bitcoin.PARAMS.TAP_TWEAK_SHA256) diff --git a/hdwallet/addresses/p2wpkh.py b/hdwallet/addresses/p2wpkh.py index 6de5da14..33db3999 100644 --- a/hdwallet/addresses/p2wpkh.py +++ b/hdwallet/addresses/p2wpkh.py @@ -23,7 +23,7 @@ class P2WPKHAddress(IAddress): - + hrp: str = Bitcoin.NETWORKS.MAINNET.HRP witness_version: int = Bitcoin.NETWORKS.MAINNET.WITNESS_VERSIONS.P2WPKH diff --git a/hdwallet/addresses/tron.py b/hdwallet/addresses/tron.py index b7828006..18369c69 100644 --- a/hdwallet/addresses/tron.py +++ b/hdwallet/addresses/tron.py @@ -57,7 +57,7 @@ def encode(cls, public_key: Union[bytes, str, IPublicKey], **kwargs: Any) -> str public_key: IPublicKey = validate_and_get_public_key( public_key=public_key, public_key_cls=SLIP10Secp256k1PublicKey ) - + address: str = bytes_to_string( kekkak256(public_key.raw_uncompressed()[1:]) )[24:] @@ -81,7 +81,7 @@ def decode(cls, address: str, **kwargs: Any) -> str: :return: Decoded public key. :rtype: str """ - + address_decode: bytes = check_decode( address, alphabet=kwargs.get( "alphabet", cls.alphabet diff --git a/hdwallet/cli/__init__.py b/hdwallet/cli/__init__.py index 29084982..776a2a4e 100644 --- a/hdwallet/cli/__init__.py +++ b/hdwallet/cli/__init__.py @@ -7,6 +7,6 @@ from bip38 import cryptocurrencies BIP38_CRYPTOCURRENCIES = { - name: cls for name, cls in inspect.getmembers(cryptocurrencies, inspect.isclass) + name: cls for name, cls in inspect.getmembers(cryptocurrencies, inspect.isclass) if issubclass(cls, cryptocurrencies.ICryptocurrency) -} \ No newline at end of file +} diff --git a/hdwallet/cli/dump.py b/hdwallet/cli/dump.py index 53bc236c..80802f4e 100644 --- a/hdwallet/cli/dump.py +++ b/hdwallet/cli/dump.py @@ -56,7 +56,7 @@ def dump(**kwargs) -> None: semantic = "p2wpkh-in-p2sh" elif kwargs.get("hd") in ["BIP84", "BIP141"]: semantic = "p2wpkh" - + hdwallet: HDWallet = HDWallet( cryptocurrency=cryptocurrency, hd=HDS.hd(name=kwargs.get("hd")), @@ -138,7 +138,7 @@ def dump(**kwargs) -> None: if kwargs.get("bip38"): bip38: BIP38 = BIP38( - cryptocurrency=BIP38_CRYPTOCURRENCIES[cryptocurrency.NAME], network=kwargs.get("network") + cryptocurrency=BIP38_CRYPTOCURRENCIES[cryptocurrency.NAME], network=kwargs.get("network") ) _wif = bip38.decrypt(encrypted_wif=_wif, passphrase=kwargs.get("passphrase")) diff --git a/hdwallet/cli/dumps.py b/hdwallet/cli/dumps.py index 73a10a58..b28f40f0 100644 --- a/hdwallet/cli/dumps.py +++ b/hdwallet/cli/dumps.py @@ -143,7 +143,7 @@ def dumps(**kwargs) -> None: if kwargs.get("bip38"): bip38: BIP38 = BIP38( - cryptocurrency=BIP38_CRYPTOCURRENCIES[cryptocurrency.NAME], network=kwargs.get("network") + cryptocurrency=BIP38_CRYPTOCURRENCIES[cryptocurrency.NAME], network=kwargs.get("network") ) _wif = bip38.decrypt(encrypted_wif=_wif, passphrase=kwargs.get("passphrase")) @@ -226,7 +226,6 @@ def dumps(**kwargs) -> None: ) ) - hd_name: str = hdwallet._hd.name() if kwargs.get("include"): _include: str = kwargs.get("include") @@ -262,7 +261,6 @@ def dumps(**kwargs) -> None: elif hdwallet.cryptocurrency() == "Binance": _include: str = "at:path,addresses:chain,public_key,wif" - hdwallet_csv = csv.DictWriter( sys.stdout, fieldnames=_include.split(","), extrasaction="ignore", delimiter=kwargs.get("delimiter") ) @@ -375,7 +373,6 @@ def drive_helper(derivations, current_derivation: List[Tuple[int, bool]] = []) - hdwallet.dump(exclude={'derivation', *excludes}), indent=4, ensure_ascii=False )) - drive(*hdwallet._derivation.derivations()) else: click.echo(click.style( diff --git a/hdwallet/consts.py b/hdwallet/consts.py index 539ec6b8..c63ee175 100644 --- a/hdwallet/consts.py +++ b/hdwallet/consts.py @@ -41,7 +41,7 @@ def __init__(self, data: Union[set, tuple, dict], **kwargs): class SLIP10_ED25519_CONST: """ ``SLIP10-ED25519`` Constants. - + +-------------------------+--------------+ | Name | Value | +=========================+==============+ @@ -61,7 +61,7 @@ class SLIP10_ED25519_CONST: class KHOLAW_ED25519_CONST(SLIP10_ED25519_CONST): """ ``KHOLAW-ED25519`` Constants. - + +-------------------------+--------------+ | Name | Value | +=========================+==============+ @@ -79,7 +79,7 @@ class KHOLAW_ED25519_CONST(SLIP10_ED25519_CONST): class SLIP10_SECP256K1_CONST: """ ``SLIP10-SECP256K1`` Constants. - + +-------------------------------------+-------------+ | Name | Value | +=====================================+=============+ diff --git a/hdwallet/cryptocurrencies/asiacoin.py b/hdwallet/cryptocurrencies/asiacoin.py index 4b02e436..2694f104 100644 --- a/hdwallet/cryptocurrencies/asiacoin.py +++ b/hdwallet/cryptocurrencies/asiacoin.py @@ -69,4 +69,3 @@ class Asiacoin(ICryptocurrency): "p2pkh", "p2sh" ] DEFAULT_SEMANTIC = "p2pkh" - diff --git a/hdwallet/cryptocurrencies/auroracoin.py b/hdwallet/cryptocurrencies/auroracoin.py index 39243d10..405172fc 100644 --- a/hdwallet/cryptocurrencies/auroracoin.py +++ b/hdwallet/cryptocurrencies/auroracoin.py @@ -70,4 +70,3 @@ class Auroracoin(ICryptocurrency): "p2pkh", "p2sh" ] DEFAULT_SEMANTIC = "p2pkh" - diff --git a/hdwallet/cryptocurrencies/bata.py b/hdwallet/cryptocurrencies/bata.py index 391b1f13..56566491 100644 --- a/hdwallet/cryptocurrencies/bata.py +++ b/hdwallet/cryptocurrencies/bata.py @@ -18,7 +18,7 @@ class Mainnet(INetwork): NAME = "mainnet" PUBLIC_KEY_ADDRESS_PREFIX = 0x19 - SCRIPT_ADDRESS_PREFIX = 0x5 + SCRIPT_ADDRESS_PREFIX = 0x5 XPRIVATE_KEY_VERSIONS = XPrivateKeyVersions({ "P2PKH": 0xa40b91bd, "P2SH": 0xa40b91bd diff --git a/hdwallet/cryptocurrencies/bitcloud.py b/hdwallet/cryptocurrencies/bitcloud.py index fd9564de..3da1e0cd 100644 --- a/hdwallet/cryptocurrencies/bitcloud.py +++ b/hdwallet/cryptocurrencies/bitcloud.py @@ -18,7 +18,7 @@ class Mainnet(INetwork): NAME = "mainnet" PUBLIC_KEY_ADDRESS_PREFIX = 0x19 - SCRIPT_ADDRESS_PREFIX = 0x5 + SCRIPT_ADDRESS_PREFIX = 0x5 XPRIVATE_KEY_VERSIONS = XPrivateKeyVersions({ "P2PKH": 0x488ade4, "P2SH": 0x488ade4 diff --git a/hdwallet/cryptocurrencies/bitcoingold.py b/hdwallet/cryptocurrencies/bitcoingold.py index b95bcada..442f9b65 100644 --- a/hdwallet/cryptocurrencies/bitcoingold.py +++ b/hdwallet/cryptocurrencies/bitcoingold.py @@ -18,7 +18,7 @@ class Mainnet(INetwork): NAME = "mainnet" PUBLIC_KEY_ADDRESS_PREFIX = 0x26 - SCRIPT_ADDRESS_PREFIX = 0x17 + SCRIPT_ADDRESS_PREFIX = 0x17 HRP = "btg" WITNESS_VERSIONS = WitnessVersions({ "P2WPKH": 0x00, diff --git a/hdwallet/cryptocurrencies/clams.py b/hdwallet/cryptocurrencies/clams.py index 2d8d449e..fb0c6104 100644 --- a/hdwallet/cryptocurrencies/clams.py +++ b/hdwallet/cryptocurrencies/clams.py @@ -71,4 +71,3 @@ class Clams(ICryptocurrency): "p2pkh", "p2sh" ] DEFAULT_SEMANTIC = "p2pkh" - \ No newline at end of file diff --git a/hdwallet/cryptocurrencies/compcoin.py b/hdwallet/cryptocurrencies/compcoin.py index cf4a4170..9ca75672 100644 --- a/hdwallet/cryptocurrencies/compcoin.py +++ b/hdwallet/cryptocurrencies/compcoin.py @@ -69,4 +69,3 @@ class Compcoin(ICryptocurrency): "p2pkh", "p2sh" ] DEFAULT_SEMANTIC = "p2pkh" - \ No newline at end of file diff --git a/hdwallet/cryptocurrencies/defcoin.py b/hdwallet/cryptocurrencies/defcoin.py index 8744d1b4..9117b9b2 100644 --- a/hdwallet/cryptocurrencies/defcoin.py +++ b/hdwallet/cryptocurrencies/defcoin.py @@ -29,7 +29,7 @@ class Mainnet(INetwork): }) MESSAGE_PREFIX = "\x18defcoin Signed Message:\n" WIF_PREFIX = 0x9e - + class Defcoin(ICryptocurrency): diff --git a/hdwallet/cryptocurrencies/dogecoin.py b/hdwallet/cryptocurrencies/dogecoin.py index bbd5f50e..225df6f4 100644 --- a/hdwallet/cryptocurrencies/dogecoin.py +++ b/hdwallet/cryptocurrencies/dogecoin.py @@ -23,7 +23,7 @@ class Mainnet(INetwork): WITNESS_VERSIONS = WitnessVersions({ "P2WPKH": 0x00, "P2WSH": 0x00 - }) + }) XPRIVATE_KEY_VERSIONS = XPrivateKeyVersions({ "DOGECOIN": 0x02fac398, "P2PKH": 0x0488ade4, diff --git a/hdwallet/cryptocurrencies/foxdcoin.py b/hdwallet/cryptocurrencies/foxdcoin.py index 2e170704..5cc9d128 100644 --- a/hdwallet/cryptocurrencies/foxdcoin.py +++ b/hdwallet/cryptocurrencies/foxdcoin.py @@ -113,4 +113,3 @@ class Foxdcoin(ICryptocurrency): "p2pkh", "p2sh", "p2wpkh", "p2wpkh-in-p2sh", "p2wsh", "p2wsh-in-p2sh" ] DEFAULT_SEMANTIC = "p2pkh" - diff --git a/hdwallet/cryptocurrencies/litecoinz.py b/hdwallet/cryptocurrencies/litecoinz.py index e91a4f8c..140b83bc 100644 --- a/hdwallet/cryptocurrencies/litecoinz.py +++ b/hdwallet/cryptocurrencies/litecoinz.py @@ -29,7 +29,7 @@ class Mainnet(INetwork): }) MESSAGE_PREFIX = "\x18LitecoinZ Signed Message:\n" WIF_PREFIX = 0x80 - + class LitecoinZ(ICryptocurrency): diff --git a/hdwallet/cryptocurrencies/potcoin.py b/hdwallet/cryptocurrencies/potcoin.py index ea21efff..47bfb912 100644 --- a/hdwallet/cryptocurrencies/potcoin.py +++ b/hdwallet/cryptocurrencies/potcoin.py @@ -29,7 +29,7 @@ class Mainnet(INetwork): }) MESSAGE_PREFIX = "\x18Potcoin Signed Message:\n" WIF_PREFIX = 0xb7 - + class Potcoin(ICryptocurrency): diff --git a/hdwallet/cryptocurrencies/rapids.py b/hdwallet/cryptocurrencies/rapids.py index 538102fb..2d8c4cd6 100644 --- a/hdwallet/cryptocurrencies/rapids.py +++ b/hdwallet/cryptocurrencies/rapids.py @@ -71,4 +71,3 @@ class Rapids(ICryptocurrency): "p2pkh", "p2sh" ] DEFAULT_SEMANTIC = "p2pkh" - diff --git a/hdwallet/cryptocurrencies/ravencoin.py b/hdwallet/cryptocurrencies/ravencoin.py index 6c663832..3be06c31 100644 --- a/hdwallet/cryptocurrencies/ravencoin.py +++ b/hdwallet/cryptocurrencies/ravencoin.py @@ -23,7 +23,7 @@ class Mainnet(INetwork): WITNESS_VERSIONS = WitnessVersions({ "P2WPKH": 0x0c, "P2WSH": 0x0c - }) + }) XPRIVATE_KEY_VERSIONS = XPrivateKeyVersions({ "P2PKH": 0x488ade4, "P2SH": 0x488ade4, diff --git a/hdwallet/cryptocurrencies/reddcoin.py b/hdwallet/cryptocurrencies/reddcoin.py index 1f9a75e9..a9f87555 100644 --- a/hdwallet/cryptocurrencies/reddcoin.py +++ b/hdwallet/cryptocurrencies/reddcoin.py @@ -72,4 +72,3 @@ class Reddcoin(ICryptocurrency): "p2pkh", "p2sh" ] DEFAULT_SEMANTIC = "p2pkh" - diff --git a/hdwallet/cryptocurrencies/rubycoin.py b/hdwallet/cryptocurrencies/rubycoin.py index fdb10817..b6053b78 100644 --- a/hdwallet/cryptocurrencies/rubycoin.py +++ b/hdwallet/cryptocurrencies/rubycoin.py @@ -1,4 +1,4 @@ - #!/usr/bin/env python3 +#!/usr/bin/env python3 # Copyright © 2020-2025, Meheret Tesfaye Batu # Distributed under the MIT software license, see the accompanying diff --git a/hdwallet/cryptocurrencies/scribe.py b/hdwallet/cryptocurrencies/scribe.py index 3af9131d..2b71a60e 100644 --- a/hdwallet/cryptocurrencies/scribe.py +++ b/hdwallet/cryptocurrencies/scribe.py @@ -29,7 +29,7 @@ class Mainnet(INetwork): }) MESSAGE_PREFIX = None WIF_PREFIX = 0x6e - + class Scribe(ICryptocurrency): diff --git a/hdwallet/cryptocurrencies/vivo.py b/hdwallet/cryptocurrencies/vivo.py index 74bc0652..a1a57985 100644 --- a/hdwallet/cryptocurrencies/vivo.py +++ b/hdwallet/cryptocurrencies/vivo.py @@ -71,4 +71,3 @@ class Vivo(ICryptocurrency): "p2pkh", "p2sh" ] DEFAULT_SEMANTIC = "p2pkh" - \ No newline at end of file diff --git a/hdwallet/cryptocurrencies/xinfin.py b/hdwallet/cryptocurrencies/xinfin.py index 14970c31..37e19450 100644 --- a/hdwallet/cryptocurrencies/xinfin.py +++ b/hdwallet/cryptocurrencies/xinfin.py @@ -70,4 +70,3 @@ class XinFin(ICryptocurrency): PARAMS = Params({ "ADDRESS_PREFIX": "xdc" }) - \ No newline at end of file diff --git a/hdwallet/derivations/__init__.py b/hdwallet/derivations/__init__.py index 28f0ef81..f8b9d1c0 100644 --- a/hdwallet/derivations/__init__.py +++ b/hdwallet/derivations/__init__.py @@ -9,13 +9,13 @@ ) from ..exceptions import DerivationError -from .bip44 import ( +from .bip44 import ( # noqa: F401 BIP44Derivation, CHANGES ) from .bip49 import BIP49Derivation from .bip84 import BIP84Derivation from .bip86 import BIP86Derivation -from .cip1852 import ( +from .cip1852 import ( # noqa: F401 CIP1852Derivation, ROLES ) from .custom import CustomDerivation diff --git a/hdwallet/derivations/bip44.py b/hdwallet/derivations/bip44.py index 559b017a..80c9a42e 100644 --- a/hdwallet/derivations/bip44.py +++ b/hdwallet/derivations/bip44.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Tuple, Union, Optional, Dict + Tuple, Union ) from ..utils import ( @@ -97,7 +97,7 @@ def name(cls) -> str: def get_change_value(self, change: Union[str, int], name_only: bool = False): if isinstance(change, (list, tuple)): raise DerivationError( - "Bad change instance", expected ="int | str", got=type(change).__name__ + "Bad change instance", expected="int | str", got=type(change).__name__ ) external_change = [CHANGES.EXTERNAL_CHAIN, 0, '0'] internal_change = [CHANGES.INTERNAL_CHAIN, 1, '1'] diff --git a/hdwallet/derivations/cip1852.py b/hdwallet/derivations/cip1852.py index 0261fa9b..e4aefe2e 100644 --- a/hdwallet/derivations/cip1852.py +++ b/hdwallet/derivations/cip1852.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Tuple, Union, Optional, Dict + Tuple, Union ) from ..utils import ( @@ -84,7 +84,7 @@ def __init__( f"{index_tuple_to_string(index=self._role)}/" f"{index_tuple_to_string(index=self._address)}" )) - + @classmethod def name(cls) -> str: """ diff --git a/hdwallet/derivations/custom.py b/hdwallet/derivations/custom.py index 8258a6f2..5593f9d1 100644 --- a/hdwallet/derivations/custom.py +++ b/hdwallet/derivations/custom.py @@ -66,7 +66,7 @@ def from_path(self, path: str) -> "CustomDerivation": raise DerivationError("Bad path instance", expected=str, got=type(path)) elif path[0:2] != "m/": raise DerivationError( - f"Bad path format", expected="like this type of path \"m/0'/0\"", got=path + "Bad path format", expected="like this type of path \"m/0'/0\"", got=path ) self._path, self._indexes, self._derivations = normalize_derivation(path=path) diff --git a/hdwallet/derivations/hdw.py b/hdwallet/derivations/hdw.py index af4baea8..f17bf5b1 100644 --- a/hdwallet/derivations/hdw.py +++ b/hdwallet/derivations/hdw.py @@ -48,7 +48,7 @@ def __init__( :param account: The HDW account index or tuple. Defaults to 0. :type account: Union[str, int, Tuple[int, int]] - :param ecc: The HDW ecc index. + :param ecc: The HDW ecc index. :type ecc: Union[str, int, Type[IEllipticCurveCryptography]] :param address: The HDW address index or tuple. Defaults to 0. :type address: Union[str, int, Tuple[int, int]] @@ -105,7 +105,7 @@ def get_ecc_value( if curve not in expected_ecc: raise DerivationError( - f"Bad {self.name()} ECC index", + f"Bad {self.name()} ECC index", expected=expected_ecc, got=curve ) diff --git a/hdwallet/eccs/__init__.py b/hdwallet/eccs/__init__.py index 33e660d0..addc0229 100644 --- a/hdwallet/eccs/__init__.py +++ b/hdwallet/eccs/__init__.py @@ -12,17 +12,17 @@ ECCError, PublicKeyError ) from ..utils import get_bytes -from .kholaw import ( +from .kholaw import ( # noqa: F401 KholawEd25519ECC, KholawEd25519Point, KholawEd25519PublicKey, KholawEd25519PrivateKey ) -from .slip10 import ( +from .slip10 import ( # noqa: F401 SLIP10Ed25519ECC, SLIP10Ed25519Point, SLIP10Ed25519PublicKey, SLIP10Ed25519PrivateKey, SLIP10Ed25519Blake2bECC, SLIP10Ed25519Blake2bPoint, SLIP10Ed25519Blake2bPublicKey, SLIP10Ed25519Blake2bPrivateKey, SLIP10Ed25519MoneroECC, SLIP10Ed25519MoneroPoint, SLIP10Ed25519MoneroPublicKey, SLIP10Ed25519MoneroPrivateKey, SLIP10Nist256p1ECC, SLIP10Nist256p1Point, SLIP10Nist256p1PublicKey, SLIP10Nist256p1PrivateKey, SLIP10Secp256k1ECC, SLIP10Secp256k1Point, SLIP10Secp256k1PublicKey, SLIP10Secp256k1PrivateKey ) -from .iecc import ( +from .iecc import ( # noqa: F401 IPoint, IPublicKey, IPrivateKey, IEllipticCurveCryptography ) @@ -132,7 +132,7 @@ def validate_and_get_public_key( :return: A valid IPublicKey instance. :rtype: IPublicKey """ - + try: if isinstance(public_key, bytes): public_key: IPublicKey = public_key_cls.from_bytes(public_key) @@ -147,7 +147,8 @@ def validate_and_get_public_key( ) return public_key except ValueError as error: - raise PublicKeyError("Invalid public key data") + raise PublicKeyError("Invalid public key data") from error + __all__: List[str] = [ "IPoint", "IPublicKey", "IPrivateKey", "IEllipticCurveCryptography", diff --git a/hdwallet/eccs/kholaw/ed25519/public_key.py b/hdwallet/eccs/kholaw/ed25519/public_key.py index 5f3d00f1..551b8738 100644 --- a/hdwallet/eccs/kholaw/ed25519/public_key.py +++ b/hdwallet/eccs/kholaw/ed25519/public_key.py @@ -31,4 +31,3 @@ def point(self) -> IPoint: """ return KholawEd25519Point(bytes(self.verify_key)) - diff --git a/hdwallet/eccs/slip10/__init__.py b/hdwallet/eccs/slip10/__init__.py index e398fb15..b7bd4929 100644 --- a/hdwallet/eccs/slip10/__init__.py +++ b/hdwallet/eccs/slip10/__init__.py @@ -30,4 +30,3 @@ "SLIP10Nist256p1ECC", "SLIP10Nist256p1Point", "SLIP10Nist256p1PublicKey", "SLIP10Nist256p1PrivateKey", "SLIP10Secp256k1ECC", "SLIP10Secp256k1Point", "SLIP10Secp256k1PublicKey", "SLIP10Secp256k1PrivateKey", ] - diff --git a/hdwallet/eccs/slip10/nist256p1/private_key.py b/hdwallet/eccs/slip10/nist256p1/private_key.py index 07f43b24..0afd207c 100644 --- a/hdwallet/eccs/slip10/nist256p1/private_key.py +++ b/hdwallet/eccs/slip10/nist256p1/private_key.py @@ -18,7 +18,7 @@ class SLIP10Nist256p1PrivateKey(IPrivateKey): - + signing_key: SigningKey def __init__(self, signing_key: SigningKey) -> None: diff --git a/hdwallet/eccs/slip10/nist256p1/public_key.py b/hdwallet/eccs/slip10/nist256p1/public_key.py index b44a7858..effc3189 100644 --- a/hdwallet/eccs/slip10/nist256p1/public_key.py +++ b/hdwallet/eccs/slip10/nist256p1/public_key.py @@ -19,7 +19,7 @@ class SLIP10Nist256p1PublicKey(IPublicKey): - + verify_key: VerifyingKey def __init__(self, verify_key: VerifyingKey) -> None: diff --git a/hdwallet/eccs/slip10/secp256k1/__init__.py b/hdwallet/eccs/slip10/secp256k1/__init__.py index 1c917df4..3cc93cc0 100644 --- a/hdwallet/eccs/slip10/secp256k1/__init__.py +++ b/hdwallet/eccs/slip10/secp256k1/__init__.py @@ -8,13 +8,13 @@ from ....consts import SLIP10_SECP256K1_CONST from ...iecc import IEllipticCurveCryptography -from .point import ( +from .point import ( # noqa: F401 SLIP10Secp256k1Point, SLIP10Secp256k1PointCoincurve, SLIP10Secp256k1PointECDSA ) -from .public_key import ( +from .public_key import ( # noqa: F401 SLIP10Secp256k1PublicKey, SLIP10Secp256k1PublicKeyCoincurve, SLIP10Secp256k1PublicKeyECDSA ) -from .private_key import ( +from .private_key import ( # noqa: F401 SLIP10Secp256k1PrivateKey, SLIP10Secp256k1PrivateKeyCoincurve, SLIP10Secp256k1PrivateKeyECDSA ) diff --git a/hdwallet/entropies/__init__.py b/hdwallet/entropies/__init__.py index 12cddaf2..0dce79be 100644 --- a/hdwallet/entropies/__init__.py +++ b/hdwallet/entropies/__init__.py @@ -9,20 +9,20 @@ ) from ..exceptions import EntropyError -from .algorand import ( +from .algorand import ( # noqa: F401 AlgorandEntropy, ALGORAND_ENTROPY_STRENGTHS ) -from .bip39 import ( +from .bip39 import ( # noqa: F401 BIP39Entropy, BIP39_ENTROPY_STRENGTHS ) -from .slip39 import ( +from .slip39 import ( # noqa: F401 SLIP39Entropy, SLIP39_ENTROPY_STRENGTHS ) -from .electrum import ( +from .electrum import ( # noqa: F401 ElectrumV1Entropy, ELECTRUM_V1_ENTROPY_STRENGTHS, ElectrumV2Entropy, ELECTRUM_V2_ENTROPY_STRENGTHS ) -from .monero import ( +from .monero import ( # noqa: F401 MoneroEntropy, MONERO_ENTROPY_STRENGTHS ) from .ientropy import IEntropy diff --git a/hdwallet/exceptions.py b/hdwallet/exceptions.py index 3b8f33cd..a71945ec 100644 --- a/hdwallet/exceptions.py +++ b/hdwallet/exceptions.py @@ -36,9 +36,11 @@ def __str__(self): class EntropyError(Error): pass + class ChecksumError(Error): pass + class MnemonicError(Error): pass @@ -100,4 +102,4 @@ class XPublicKeyError(Error): class XPrivateKeyError(Error): - pass \ No newline at end of file + pass diff --git a/hdwallet/hds/algorand.py b/hdwallet/hds/algorand.py index 17e3b1b5..91478b2c 100644 --- a/hdwallet/hds/algorand.py +++ b/hdwallet/hds/algorand.py @@ -62,10 +62,10 @@ def from_seed(self, seed: Union[bytes, str, ISeed], **kwargs) -> "AlgorandHD": seed.seed() if isinstance(seed, ISeed) else seed ) except ValueError as error: - raise SeedError("Invalid seed data") + raise SeedError("Invalid seed data") from error if len(self._seed) < 16: - raise Error(f"Invalid seed length", expected="< 16", got=len(self._seed)) + raise Error("Invalid seed length", expected="< 16", got=len(self._seed)) def clamp_kL(kL: bytearray): kL[0] &= 0b11111000 @@ -235,7 +235,7 @@ def xprivate_key( :rtype: Optional[str] """ return super(AlgorandHD, self).xprivate_key(version=version, encoded=encoded) - + def address(self, **kwargs) -> str: """ Generates a Algorand address using the AlgorandAddress encoding scheme. diff --git a/hdwallet/hds/bip141.py b/hdwallet/hds/bip141.py index bbb61194..99862cfb 100644 --- a/hdwallet/hds/bip141.py +++ b/hdwallet/hds/bip141.py @@ -202,7 +202,7 @@ def xpublic_key( """ return super(BIP141HD, self).xpublic_key( - version=(self._xpublic_key_version if version is None else version) , encoded=encoded + version=(self._xpublic_key_version if version is None else version), encoded=encoded ) def address( diff --git a/hdwallet/hds/bip32.py b/hdwallet/hds/bip32.py index f9b05d6b..c35158e2 100644 --- a/hdwallet/hds/bip32.py +++ b/hdwallet/hds/bip32.py @@ -66,7 +66,7 @@ class BIP32HD(IHD): _root_index: int = 0 _depth: int = 0 _index: int = 0 - + def __init__( self, ecc: Type[IEllipticCurveCryptography], public_key_type: str = PUBLIC_KEY_TYPES.COMPRESSED, **kwargs ) -> None: @@ -141,10 +141,10 @@ def from_seed(self, seed: Union[bytes, str, ISeed], **kwargs) -> "BIP32HD": seed.seed() if isinstance(seed, ISeed) else seed ) except ValueError as error: - raise SeedError("Invalid seed data") + raise SeedError("Invalid seed data") from error if len(self._seed) < 16: - raise Error(f"Invalid seed length", expected="< 16", got=len(self._seed)) + raise Error("Invalid seed length", expected="< 16", got=len(self._seed)) hmac_half_length: int = hashlib.sha512().digest_size // 2 @@ -338,7 +338,7 @@ def from_private_key(self, private_key: str) -> "BIP32HD": self._strict = None return self except ValueError as error: - raise PrivateKeyError("Invalid private key data") + raise PrivateKeyError("Invalid private key data") from error def from_public_key(self, public_key: str) -> "BIP32HD": """ @@ -356,7 +356,7 @@ def from_public_key(self, public_key: str) -> "BIP32HD": self._strict = None return self except ValueError as error: - raise PublicKeyError("Invalid public key data") + raise PublicKeyError("Invalid public key data") from error def from_derivation(self, derivation: IDerivation) -> "BIP32HD": """ diff --git a/hdwallet/hds/bip86.py b/hdwallet/hds/bip86.py index e4589583..5cde29de 100644 --- a/hdwallet/hds/bip86.py +++ b/hdwallet/hds/bip86.py @@ -24,7 +24,7 @@ class BIP86HD(BIP44HD): _derivation: BIP86Derivation def __init__( - self, ecc: Type[IEllipticCurveCryptography], public_key_type: str = PUBLIC_KEY_TYPES.COMPRESSED, **kwargs + self, ecc: Type[IEllipticCurveCryptography], public_key_type: str = PUBLIC_KEY_TYPES.COMPRESSED, **kwargs ) -> None: """ Initialize a BIP86HD instance. diff --git a/hdwallet/hds/cardano.py b/hdwallet/hds/cardano.py index 48678164..1a3c94c5 100644 --- a/hdwallet/hds/cardano.py +++ b/hdwallet/hds/cardano.py @@ -87,11 +87,11 @@ def from_seed(self, seed: Union[str, ISeed], passphrase: Optional[str] = None) - seed.seed() if isinstance(seed, ISeed) else seed ) except ValueError as error: - raise SeedError("Invalid seed data") + raise SeedError("Invalid seed data") from error if self._cardano_type == Cardano.TYPES.BYRON_LEGACY: if len(self._seed) != 32: - raise Error(f"Invalid seed length", expected=32, got=len(self._seed)) + raise Error("Invalid seed length", expected=32, got=len(self._seed)) def tweak_master_key_bits(data: bytes) -> bytes: data: bytearray = bytearray(data) @@ -127,7 +127,7 @@ def tweak_master_key_bits(data: bytes) -> bytes: Cardano.TYPES.BYRON_ICARUS, Cardano.TYPES.SHELLEY_ICARUS ]: if len(self._seed) < 16: - raise Error(f"Invalid seed length", expected="< 16", got=len(self._seed)) + raise Error("Invalid seed length", expected="< 16", got=len(self._seed)) pbkdf2_passphrase, pbkdf2_rounds, pbkdf2_output_length = ( (passphrase if passphrase else ""), 4096, 96 @@ -159,7 +159,7 @@ def tweak_master_key_bits(data: bytes) -> bytes: Cardano.TYPES.BYRON_LEDGER, Cardano.TYPES.SHELLEY_LEDGER ]: if len(self._seed) < 16: - raise Error(f"Invalid seed length", expected="< 16", got=len(self._seed)) + raise Error("Invalid seed length", expected="< 16", got=len(self._seed)) hmac_half_length: int = hashlib.sha512().digest_size // 2 @@ -232,7 +232,7 @@ def from_private_key(self, private_key: str) -> "CardanoHD": self._strict = None return self except ValueError as error: - raise PrivateKeyError("Invalid private key data") + raise PrivateKeyError("Invalid private key data") from error def from_public_key(self, public_key: str) -> "CardanoHD": """ @@ -254,7 +254,7 @@ def from_public_key(self, public_key: str) -> "CardanoHD": self._strict = None return self except ValueError as error: - raise PublicKeyError("Invalid public key data") + raise PublicKeyError("Invalid public key data") from error def drive(self, index: int) -> Optional["CardanoHD"]: """ @@ -334,7 +334,7 @@ def new_private_key_right_part(zr: bytes, kr: bytes) -> bytes: ), endianness="little" ) - z_hmacl, z_hmacr, _hmacl, _hmacr = ( + z_hmacl, z_hmacr, _hmacl, _hmacr = ( # noqa: F841 z_hmac[:hmac_half_length], z_hmac[hmac_half_length:], _hmac[:hmac_half_length], _hmac[hmac_half_length:] ) @@ -376,7 +376,7 @@ def new_public_key_point(public_key: IPublicKey, zl: bytes, ecc: IEllipticCurveC zl: int = bytes_to_integer(zl[:28], endianness="little") return public_key.point() + ((zl * 8) * ecc.GENERATOR) - z_hmacl, z_hmacr, _hmacl, _hmacr = ( + z_hmacl, z_hmacr, _hmacl, _hmacr = ( # noqa: F841 z_hmac[:hmac_half_length], z_hmac[hmac_half_length:], _hmac[:hmac_half_length], _hmac[hmac_half_length:] ) diff --git a/hdwallet/hds/electrum/v1.py b/hdwallet/hds/electrum/v1.py index d2cabe4e..75265d0a 100644 --- a/hdwallet/hds/electrum/v1.py +++ b/hdwallet/hds/electrum/v1.py @@ -114,7 +114,7 @@ def from_seed(self, seed: Union[bytes, str, ISeed], **kwargs) -> "ElectrumV1HD": self.from_private_key(private_key=self._seed) return self except ValueError as error: - raise SeedError("Invalid seed data") + raise SeedError("Invalid seed data") from error def from_private_key(self, private_key: Union[bytes, str, IPrivateKey]) -> "ElectrumV1HD": """ @@ -139,7 +139,7 @@ def from_private_key(self, private_key: Union[bytes, str, IPrivateKey]) -> "Elec self.__update__() return self except ValueError as error: - raise PrivateKeyError("Invalid private key data") + raise PrivateKeyError("Invalid private key data") from error def from_wif(self, wif: str) -> "ElectrumV1HD": """ @@ -180,7 +180,7 @@ def from_public_key(self, public_key: Union[bytes, str, IPublicKey]) -> "Electru self.__update__() return self except ValueError as error: - raise PublicKeyError("Invalid public key error") + raise PublicKeyError("Invalid public key error") from error def from_derivation(self, derivation: IDerivation) -> "ElectrumV1HD": """ diff --git a/hdwallet/hds/electrum/v2.py b/hdwallet/hds/electrum/v2.py index 672a6581..df985999 100644 --- a/hdwallet/hds/electrum/v2.py +++ b/hdwallet/hds/electrum/v2.py @@ -23,7 +23,7 @@ PUBLIC_KEY_TYPES, MODES, WIF_TYPES ) from ...exceptions import ( - Error, DerivationError, AddressError, WIFError + Error, DerivationError, AddressError ) from ..bip32 import BIP32HD from ..ihd import IHD @@ -117,7 +117,7 @@ def from_seed(self, seed: Union[bytes, str, ISeed], **kwargs) -> "ElectrumV2HD": self._bip32_hd.from_seed(seed=seed) self.__update__() return self - + def from_derivation(self, derivation: IDerivation) -> "ElectrumV2HD": """ Initialize the instance from a derivation. diff --git a/hdwallet/hds/ihd.py b/hdwallet/hds/ihd.py index e8238079..04da4cef 100644 --- a/hdwallet/hds/ihd.py +++ b/hdwallet/hds/ihd.py @@ -453,7 +453,6 @@ def public_key_type(self) -> str: :rtype: str """ - def mode(self) -> str: """ Get the mode of the ElectrumV2HD instance. @@ -537,7 +536,7 @@ def strict(self) -> Optional[bool]: def integrated_address(self, **kwargs) -> str: """ Generates the integrated Monero address associated with the spend and view public keys. - + :param kwargs: Additional keyword arguments. :return: Integrated Monero address. @@ -547,7 +546,7 @@ def integrated_address(self, **kwargs) -> str: def primary_address(self, **kwargs) -> str: """ Generates the primary Monero address associated with the spend and view public keys. - + :param kwargs: Additional keyword arguments. :return: Primary Monero address. @@ -559,7 +558,7 @@ def sub_address(self, **kwargs) -> str: Generates a sub-address associated with the given minor and major indexes or uses the current derivation indexes. :param kwargs: Additional keyword arguments. - + :return: Generated sub-address. :rtype: str """ @@ -572,4 +571,4 @@ def address(self, **kwargs) -> str: :return: The generated address. :rtype: str - """ \ No newline at end of file + """ diff --git a/hdwallet/hds/monero.py b/hdwallet/hds/monero.py index a2ae3481..f3814698 100644 --- a/hdwallet/hds/monero.py +++ b/hdwallet/hds/monero.py @@ -119,7 +119,7 @@ def from_seed(self, seed: Union[bytes, str, ISeed], **kwargs) -> "MoneroHD": spend_private_key=scalar_reduce(spend_private_key) ) except ValueError as error: - raise SeedError("Invalid seed data") + raise SeedError("Invalid seed data") from error def from_private_key(self, private_key: Union[bytes, str, IPrivateKey]) -> "MoneroHD": """ @@ -140,7 +140,7 @@ def from_private_key(self, private_key: Union[bytes, str, IPrivateKey]) -> "Mone spend_private_key=scalar_reduce(kekkak256(self._private_key)) ) except ValueError as error: - raise PrivateKeyError("Invalid private key data") + raise PrivateKeyError("Invalid private key data") from error def from_derivation(self, derivation: IDerivation) -> "MoneroHD": """ @@ -225,13 +225,13 @@ def from_watch_only( if isinstance(view_private_key, (bytes, str)): view_private_key: IPrivateKey = SLIP10Ed25519MoneroPrivateKey.from_bytes(get_bytes(view_private_key)) except ValueError as error: - raise PrivateKeyError("Invalid view private key data") + raise PrivateKeyError("Invalid view private key data") from error try: if isinstance(spend_public_key, (bytes, str)): spend_public_key: IPublicKey = SLIP10Ed25519MoneroPublicKey.from_bytes(get_bytes(spend_public_key)) except ValueError as error: - raise PublicKeyError("Invalid spend public key data") + raise PublicKeyError("Invalid spend public key data") from error self._spend_private_key = None self._view_private_key = view_private_key @@ -255,11 +255,11 @@ def drive(self, minor_index: int, major_index: int) -> Tuple[IPublicKey, IPublic maximum_index: int = 2 ** 32 - 1 if minor_index < 0 or minor_index > maximum_index: raise DerivationError( - f"Invalid minor index range", expected=f"0-{maximum_index}", got=minor_index + "Invalid minor index range", expected=f"0-{maximum_index}", got=minor_index ) if major_index < 0 or major_index > maximum_index: raise DerivationError( - f"Invalid major index range", expected=f"0-{maximum_index}", got=major_index + "Invalid major index range", expected=f"0-{maximum_index}", got=major_index ) if minor_index == 0 and major_index == 0: diff --git a/hdwallet/hdwallet.py b/hdwallet/hdwallet.py index 0ba19019..1c78059a 100644 --- a/hdwallet/hdwallet.py +++ b/hdwallet/hdwallet.py @@ -715,7 +715,7 @@ def from_spend_private_key( self._hd.from_spend_private_key(spend_private_key=spend_private_key) return self except ValueError as error: - raise PrivateKeyError("Invalid spend private key data") + raise PrivateKeyError("Invalid spend private key data") from error def from_watch_only( self, diff --git a/hdwallet/libs/base58.py b/hdwallet/libs/base58.py index 5a918155..f0270085 100644 --- a/hdwallet/libs/base58.py +++ b/hdwallet/libs/base58.py @@ -27,7 +27,7 @@ def checksum_encode(address, crypto="eth"): def string_to_int(data): val = 0 - if type(data) == str: + if isinstance(data, str): data = bytearray(data) for (i, c) in enumerate(data[::-1]): diff --git a/hdwallet/libs/ecc.py b/hdwallet/libs/ecc.py index 7c9a2b2a..5e051f6f 100644 --- a/hdwallet/libs/ecc.py +++ b/hdwallet/libs/ecc.py @@ -232,7 +232,7 @@ class S256Point(Point): def __init__(self, x, y, a=None, b=None): a, b = S256Field(A), S256Field(B) - if type(x) == int: + if type(x) == int: # noqa: E721 super().__init__(x=S256Field(x), y=S256Field(y), a=a, b=b) else: super().__init__(x=x, y=y, a=a, b=b) @@ -423,4 +423,4 @@ def wif(self, compressed=True, testnet=False): else: suffix = b'' # encode_base58_checksum the whole thing - return encode_base58_checksum(prefix + secret_bytes + suffix) \ No newline at end of file + return encode_base58_checksum(prefix + secret_bytes + suffix) diff --git a/hdwallet/seeds/cardano.py b/hdwallet/seeds/cardano.py index 86be907c..8e877b82 100644 --- a/hdwallet/seeds/cardano.py +++ b/hdwallet/seeds/cardano.py @@ -109,7 +109,8 @@ def is_valid(cls, seed: str, cardano_type: str = Cardano.TYPES.BYRON_ICARUS) -> if not isinstance(seed, str) or not bool(re.fullmatch( r'^[0-9a-fA-F]+$', seed - )): return False + )): + return False if cardano_type in [Cardano.TYPES.BYRON_ICARUS, Cardano.TYPES.SHELLEY_ICARUS]: return len(seed) == cls.lengths[0] diff --git a/hdwallet/utils.py b/hdwallet/utils.py index 252a22c0..30abe1ad 100644 --- a/hdwallet/utils.py +++ b/hdwallet/utils.py @@ -90,7 +90,7 @@ def path_to_indexes(path: str) -> List[int]: return [] elif path[0:2] != "m/": raise DerivationError( - f"Bad path format", expected="like this type of path \"m/0'/0\"", got=path + "Bad path format", expected="like this type of path \"m/0'/0\"", got=path ) indexes: List[int] = [] @@ -139,17 +139,17 @@ def normalize_index( if isinstance(index, tuple): if len(index) != 2: raise DerivationError( - f"Bad index length", expected=2, got=len(index) + "Bad index length", expected=2, got=len(index) ) elif not isinstance(index[0], int) or not isinstance(index[1], int): raise DerivationError( - f"Invalid index types", + "Invalid index types", expected="both indexes must be integer instance", got=f"{type(index[0])}-{type(index[0])}" ) elif index[0] < 0 or index[1] < 0: raise DerivationError( - f"Bad index format", expected="both must be non-negative-numbers", got=index + "Bad index format", expected="both must be non-negative-numbers", got=index ) elif index[0] > index[1]: raise DerivationError( @@ -175,18 +175,18 @@ def normalize_index( ) return from_index, to_index, hardened raise DerivationError( - f"Bad index format", expected="{non-negative-number} | {number}-{number}", got=index + "Bad index format", expected="{non-negative-number} | {number}-{number}", got=index ) elif isinstance(index, int): if index < 0: raise DerivationError( - f"Bad index format", expected="non-negative-number", got=index + "Bad index format", expected="non-negative-number", got=index ) return index, hardened raise DerivationError( - f"Invalid index instance", expected=(str, int, tuple), got=type(index) + "Invalid index instance", expected=(str, int, tuple), got=type(index) ) @@ -223,7 +223,7 @@ def normalize_derivation( return f"{_path}/", _indexes, _derivations elif path[0:2] != "m/": raise DerivationError( - f"Bad path format", expected="like this type of path \"m/0'/0\"", got=path + "Bad path format", expected="like this type of path \"m/0'/0\"", got=path ) elif not path: return f"{_path}/", _indexes, _derivations @@ -778,7 +778,7 @@ def words_to_bytes_chunk( words_list[i]: i for i in range(len(words_list)) } - word_1_index, word_2_index, word_3_index = ( + word_1_index, word_2_index, word_3_index = ( words_list_with_index[word_1], words_list_with_index[word_2] % words_list_length, words_list_with_index[word_3] % words_list_length ) diff --git a/hdwallet/wif.py b/hdwallet/wif.py index 795ffb3f..e4ac3909 100644 --- a/hdwallet/wif.py +++ b/hdwallet/wif.py @@ -71,7 +71,7 @@ def decode_wif( raw: bytes = decode(wif) if not raw.startswith(integer_to_bytes(wif_prefix)): - raise WIFError(f"Invalid Wallet Import Format (WIF)") + raise WIFError("Invalid Wallet Import Format (WIF)") prefix_length: int = len(integer_to_bytes(wif_prefix)) prefix_got: bytes = raw[:prefix_length] @@ -84,7 +84,7 @@ def decode_wif( wif_type: str = "wif" if len(private_key) not in [33, 32]: - raise WIFError(f"Invalid Wallet Import Format (WIF)") + raise WIFError("Invalid Wallet Import Format (WIF)") elif len(private_key) == 33: private_key = private_key[:-len(integer_to_bytes(SLIP10_SECP256K1_CONST.PRIVATE_KEY_COMPRESSED_PREFIX))] wif_type = "wif-compressed" From c5bec4e70d13c3f312cb464442dafcdccc4f306f Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Fri, 29 Aug 2025 13:16:15 -0600 Subject: [PATCH 17/38] Begin attempt to match unambiguous abbrevs --- hdwallet/cli/__main__.py | 16 + hdwallet/cli/generate/mnemonic.py | 54 + hdwallet/mnemonics/bip39/mnemonic.py | 22 +- hdwallet/mnemonics/imnemonic.py | 218 ++- hdwallet/mnemonics/monero/mnemonic.py | 2 +- hdwallet/mnemonics/slip39/mnemonic.py | 98 +- hdwallet/seeds/bip39.py | 6 +- requirements.txt | 1 + requirements/cli.txt | 1 - .../mnemonics/test_mnemonics_slip39.py | 20 +- tests/test_bip39_cross_language.py | 205 +++ tests/test_bip39_normalization.py | 210 +++ tests/test_unicode_normalization.py | 1166 +++++++++++++++++ 13 files changed, 1954 insertions(+), 65 deletions(-) create mode 100644 tests/test_bip39_cross_language.py create mode 100644 tests/test_bip39_normalization.py create mode 100644 tests/test_unicode_normalization.py diff --git a/hdwallet/cli/__main__.py b/hdwallet/cli/__main__.py index 04538959..3b2e679b 100644 --- a/hdwallet/cli/__main__.py +++ b/hdwallet/cli/__main__.py @@ -104,15 +104,31 @@ def cli_entropy(**kwargs) -> None: @click.option( "-l", "--language", type=str, default=None, help="Set Mnemonic language", show_default=True ) +@click.option( + "-p", "--passphrase", type=str, default=None, help="Set Mnemonic passphrase for SLIP39", show_default=True +) +@click.option( + "-t", "--tabulate", type=int, default=False, help="Set Mnemonic tabulation SLIP39", show_default=True +) +# Sources of entropy for the mnemonic; raw 'entropy', 'words', or another 'mnemonic' of 'mnemonic_type' @click.option( "-e", "--entropy", type=str, default=None, help="Set Mnemonic entropy", show_default=True ) @click.option( "-w", "--words", type=int, default=None, help="Set Mnemonic words", show_default=True ) +@click.option( + "-mc", "--mnemonic-client", type=str, default="BIP39", help="Select entropy Mnemonic client", show_default=True +) +@click.option( + "-m", "--mnemonic", multiple=True, help="Set entropy Mnemonic(s)" +) @click.option( "-mt", "--mnemonic-type", type=str, default="standard", help="Set Mnemonic type for Electrum-V2", show_default=True ) +@click.option( + "-mp", "--mnemonic-passphrase", type=str, default=None, help="Set entropy Mnemonic passphrase", show_default=True +) @click.option( "-max", "--max-attempts", type=int, default=(10 ** 60), help="Set Max attempts for Electrum-V2", show_default=True ) diff --git a/hdwallet/cli/generate/mnemonic.py b/hdwallet/cli/generate/mnemonic.py index f4c495bd..1968cafe 100644 --- a/hdwallet/cli/generate/mnemonic.py +++ b/hdwallet/cli/generate/mnemonic.py @@ -21,6 +21,10 @@ def generate_mnemonic(**kwargs) -> None: + """Produce a Mnemonic of type 'client' in 'language'. Source from 'entropy' or 'mnemonic', or + produce new entropy appropriate for a certain number of mnemonic 'words'. + + """ try: if not MNEMONICS.is_mnemonic(name=kwargs.get("client")): click.echo(click.style( @@ -74,6 +78,40 @@ def generate_mnemonic(**kwargs) -> None: ), err=True) sys.exit() + if kwargs.get("entropy") and kwargs.get("mnemonic"): + click.echo(click.style( + f"Supply either --entropy or --mnemonic, not both, " + ), err=True) + sys.exit() + + if kwargs.get("mnemonic"): + # Get source entropy from another mnemonic. Doesn't support those requiring another + # different 'mnemonic_type' from that supplied for the output mnemonic. Recovering the + # original entropy from certain Mnemonics such as SLIP39 requires an optional + # passphrase. For most Mnemonic clients, a passphrase doesn't hide the original entropy + # -- it is used only when deriving wallets. + if not MNEMONICS.is_mnemonic(name=kwargs.get("mnemonic_client")): + click.echo(click.style( + f"Wrong mnemonic client, (expected={MNEMONICS.names()}, got='{kwargs.get('mnemonic_client')}')" + ), err=True) + sys.exit() + if kwargs.get("mnemonic_client") == ElectrumV2Mnemonic.name(): + entropy: str = ElectrumV2Mnemonic.decode( + mnemonic=kwargs.get("mnemonic"), + mnemonic_type=kwargs.get("mnemonic_type") + ) + elif kwargs.get("mnemonic_client") == SLIP39Mnemonic.name(): + entropy: str = SLIPMnemonic.decode( + mnemonic=kwargs.get("mnemonic"), + passphrase=kwargs.get("mnemonic_passphrase") or "", + ) + else: + entropy: str = MNEMONICS.mnemonic(name=kwargs.get("mnemonic_client")).decode( + mnemonic=kwargs.get("mnemonic"), + ) + # Now, use the recovered 'entropy' in deriving the new 'client' mnemonic. + kwargs["entropy"] = entropy + if kwargs.get("entropy"): if kwargs.get("client") == ElectrumV2Mnemonic.name(): mnemonic: IMnemonic = ElectrumV2Mnemonic( @@ -93,6 +131,22 @@ def generate_mnemonic(**kwargs) -> None: checksum=kwargs.get("checksum") ) ) + elif kwargs.get("client") == SLIP39Mnemonic.name(): + # The supplied 'entropy', encoded w/ the SLIP-39 'language', and encrypted w/ + # 'passphrase' (default: ""). We remember the supplied language, because it + # deterministically describes the SLIP-39 secret and group encoding parameters, and + # can also contain specifics like the SLIP-39's overall name and groups' names. Any + # 'tabulate' supplied influences the formatting of the groups of SLIP-39 Mnemonics. + mnemonic: IMnemonic = SLIP39Mnemonic( + mnemonic=SLILP39Mnemonic.from_entropy( + entropy=kwargs.get("entropy"), + language=language, + passphrase=kwargs.get("passphrase") or "", + checksum=kwargs.get("checksum") + ), + language=language, + tabulate=kwargs.get("tabulate", False), + ) else: mnemonic: IMnemonic = MNEMONICS.mnemonic(name=kwargs.get("client")).__call__( mnemonic=MNEMONICS.mnemonic(name=kwargs.get("client")).from_entropy( diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index f0c26579..dcfb4144 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -228,7 +228,7 @@ def encode(cls, entropy: Union[str, bytes], language: str) -> str: mnemonic_bin: str = entropy_binary_string + entropy_hash_binary_string[:len(entropy) // 4] mnemonic: List[str] = [] - words_list: List[str] = cls.normalize(cls.get_words_list_by_language(language=language)) + words_list: List[str] = cls.get_words_list_by_language(language=language) # Already NFC normalized if len(words_list) != cls.words_list_number: raise Error( "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) @@ -239,11 +239,16 @@ def encode(cls, entropy: Union[str, bytes], language: str) -> str: word_index: int = binary_string_to_integer(word_bin) mnemonic.append(words_list[word_index]) - return " ".join(cls.normalize(mnemonic)) + return " ".join(mnemonic) # Words from wordlist are already properly normalized @classmethod def decode( - cls, mnemonic: str, checksum: bool = False, words_list: Optional[List[str]] = None, words_list_with_index: Optional[dict] = None + cls, + mnemonic: str, + language: Optional[str], + checksum: bool = False, + words_list: Optional[List[str]] = None, + words_list_with_index: Optional[Dict[str, int]] = None, ) -> str: """ Decodes a mnemonic phrase into its corresponding entropy. @@ -253,6 +258,8 @@ def decode( :param mnemonic: The mnemonic phrase to decode. :type mnemonic: str + :param language: The preferred language of the mnemonic phrase + :type language: Optional[str] :param checksum: Whether to include the checksum in the returned entropy. :type checksum: bool :param words_list: Optional list of words used to decode the mnemonic. If not provided, the method will use the default word list for the language detected. @@ -264,19 +271,16 @@ def decode( :rtype: str """ - words: list = cls.normalize(mnemonic) + words: list = cls.normalize(mnemonic, language=language) if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) if not words_list or not words_list_with_index: - words_list, language = cls.find_language(mnemonic=words) - if len(words_list) != cls.words_list_number: + words_list_with_index, language = cls.find_language(mnemonic=words, language=language) + if len(set(words_list_with_index.values())) != cls.words_list_number: raise Error( "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) ) - words_list_with_index: dict = { - words_list[i]: i for i in range(len(words_list)) - } if len(words_list) != cls.words_list_number: raise Error( diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index cb63ba5a..3af05d81 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -8,25 +8,30 @@ ABC, abstractmethod ) from typing import ( - Union, Dict, List, Tuple, Optional + Union, Dict, Set, List, Tuple, Optional ) import os import string import unicodedata +from collections import defaultdict + from ..exceptions import MnemonicError from ..entropies import IEntropy class IMnemonic(ABC): + # The specified Mnemonic's details; including the deduced language and all of its word indices + # for decoding, including valid abbreviations and word with/without the accents. _mnemonic: List[str] _words: int _language: str _mnemonic_type: Optional[str] + _word_indices: Dict[str, int] - words_list: List[int] + words_list: List[int] # The valid mnemonic length(s) available, in words languages: List[str] wordlist_path: Dict[str, str] @@ -45,8 +50,9 @@ def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: self._mnemonic: List[str] = self.normalize(mnemonic) if not self.is_valid(self._mnemonic, **kwargs): raise MnemonicError("Invalid mnemonic words") - - _, self._language = self.find_language(self._mnemonic) + # Attempt to unambiguously determine the Mnemonic's language using the preferred 'language' + # optionally provided. + self._word_indices, self._language = self.find_language(self._mnemonic, language=kwargs.get("language")) self._mnemonic_type = kwargs.get("mnemonic_type", None) self._words = len(self._mnemonic) @@ -115,7 +121,10 @@ def decode(cls, mnemonic: Union[str, List[str]], **kwargs) -> str: def get_words_list_by_language( cls, language: str, wordlist_path: Optional[Dict[str, str]] = None ) -> List[str]: - """Retrieves the standardized (normal form KD, lower-cased) word list for the specified language. + """Retrieves the standardized (NFC normalized, lower-cased) word list for the specified language. + + Uses NFC normalization for internal processing consistency. BIP-39 wordlists are stored in NFD + format but we normalize to NFC for internal word comparisons and lookups. We do not want to use 'normalize' to do this, because normalization of Mnemonics may have additional functionality beyond just ensuring symbol and case standardization. @@ -125,7 +134,7 @@ def get_words_list_by_language( :param wordlist_path: Optional dictionary mapping language names to file paths of their word lists. :type wordlist_path: Optional[Dict[str, str]] - :return: A list of words for the specified language. + :return: A list of words for the specified language, normalized to NFC form. :rtype: List[str] """ @@ -133,45 +142,193 @@ def get_words_list_by_language( wordlist_path = cls.wordlist_path if wordlist_path is None else wordlist_path with open(os.path.join(os.path.dirname(__file__), wordlist_path[language]), "r", encoding="utf-8") as fin: words_list: List[str] = [ - unicodedata.normalize("NFKD", word.lower()) + unicodedata.normalize("NFC", word.lower()) for word in map(str.strip, fin.readlines()) if word and not word.startswith("#") ] return words_list @classmethod - def find_language( - cls, mnemonic: List[str], wordlist_path: Optional[Dict[str, str]] = None - ) -> Union[str, Tuple[List[str], str]]: + def all_wordslist_indices( + cls, wordlist_path: Optional[Dict[str, str]] = None + ) -> Tuple[str, List[str], Dict[str, int]]: + """Yields each 'candidate' language, its NFKC-normalized 'words_list', and its + 'word_indices' dict including optional accents and all unique abbreviations. + """ - Finds the language of the given mnemonic by checking against available word lists. + def abbreviated_indices( word_indices ): + """We will support all unambiguous abbreviations; even down to less than 4 characters + (the typically guaranteed /minimum/ unambiguous word size in most Mnemonic encodings.) + This is because Mnemonic inputs often support the absolute minimum input required to + uniquely identify a mnemonic word in a specified language. + + """ + def min_disambiguating_length(word1: str, word2: str): + """Find the minimum length needed to disambiguate word1 from word2. Since the words + cannot be the same, they must differ at a valid index, or one must be longer, so the + resultant index is always a valid index into at least the longer of the two words. + + """ + assert word1 != word2, \ + f"Cannot disambiguate empty or identical words" + for j in range(min(len(word1), len(word2))): + if word1[j] != word2[j]: + return j + 1 + # One is a prefix of the other; first non-prefix character disambiguates + return j + 1 + + words_sorted = sorted( word_indices ) + + pair_disambiguation = list( + min_disambiguating_length( w1, w2 ) + for w1, w2 in zip( words_sorted[0:], words_sorted[1:] ) + ) + for i in range( len( words_sorted ) - 2 ): + beg = min( + pair_disambiguation[i-1 if i > 0 else i], + pair_disambiguation[i], + pair_disambiguation[i+1] + ) + end = min( + len( words_sorted[i-1 if i > 0 else i] ), + len( words_sorted[i] ), + len( words_sorted[i+1] ) + ) + for length in range( beg, end - 1): + abbrev = words_sorted[i][:length] + assert abbrev not in words_sorted, \ + f"Found {abbrev} in {words_sorted!r}" + yield abbrev, word_indices[words_sorted[i]] + + for candidate in wordlist_path.keys() if wordlist_path else cls.languages: + # Normalized NFC, so characters and accents are combined + words_list: List[str] = cls.get_words_list_by_language( + language=candidate, wordlist_path=wordlist_path + ) + word_indices: Dict[str,int] = { + words_list[i]: i for i in range(len( words_list )) + } + + def unmark( word_composed ): + """This word may contain composite characters with accents like "é" that decompose "e + + '". Most mnemonic encodings require that mnemonic words without accents match + the accented word. Remove the non-character symbols.""" + return ''.join( + c + for c in unicodedata.normalize( "NFD", word_composed ) + if not unicodedata.category( c ).startswith('M') + ) + + word_indices_unmarked = { + unmark( word_composed ): i + for word_composed, i in word_indices.items() + } + word_indices.update( word_indices_unmarked ) + + word_indices_abbreviated = dict( abbreviated_indices( word_indices )) + word_indices.update( word_indices_abbreviated ) + + yield candidate, words_list, word_indices + + @classmethod + def find_language( + cls, + mnemonic: List[str], + wordlist_path: Optional[Dict[str, str]] = None, + language: Optional[str] = None, + ) -> Tuple[Dict[str, int], str]: + """Finds the language of the given mnemonic by checking against available word list(s), + preferring the specified 'language' if one is supplied. If a 'wordlist_path' dict of + {language: path} is supplied, its languages are used. If a 'language' (optional) is + supplied, any ambiguity is resolved by selecting the preferred language, if available and + the mnemonic matches. If not, the least ambiguous language found is selected. + + If an abbreviation match is found, then the language with the largest total number of + symbols matched (least ambiguity) is considered best. This handles the (rare) case where a + mnemonic is valid in multiple languages, either directly or as an abbreviation (or + completely valid in both languages): + + english: abandon about badge machine minute ozone salon science ... + french: abandon aboutir badge machine minute ozone salon science ... + + Clearly, it is /possible/ to specify a Mnemonic which for which it is impossible to uniquely + determine the language! However, this Mnemonic would probably be encoding very poor + entropy, so is quite unlikely to occur in a Mnemonic storing true entropy. But, it is + certainly possible (see above). However, especially with abbreviations, it is possible for + this to occur. For these Mnemonics, it is /impossible/ to know (or guess) which language + the Mnemonic was intended to be {en,de}coded with. Since an incorrect "guess" would lead to + a different seed and therefore different derived wallets -- a match to multiple languages + with the same quality and with no preferred 'language' leads to an Exception. + + Even the final word (whchi encodes some checksum bits) cannot determine the language with + finality, because it is only a statistical checksum! For 128-bit 12-word encodings, only 4 + bits of checksum are represented. Therefore, there is a 1/16 chance that any entropy that + encodes to words in both languages will *also* have the same 4 bits of checksum! 24-word + BIP-39 Mnemonics only encode 8 bits of checksum, so 1/256 of random entropy that encodes to + words common to both languages will pass the checksum test. + + Therefore, specifying a 'language' is necessary to eliminate the possibility of erroneously + recognizing the wrong language for some Mnemonic, and therefore producing the wrong derived + cryptographic keys. + + + The returned Dict[str, int] contains all accepted word -> index mappings, including all + acceptable abbreviations, with and without character accents. This is typically the + expected behavior for most Mnemonic encodings ('café' == 'cafe' for Mnemonic word matching). :param mnemonic: The mnemonic to check, represented as a list of words. :type mnemonic: List[str] :param wordlist_path: Optional dictionary mapping language names to file paths of their word lists. :type wordlist_path: Optional[Dict[str, str]] + :param language: The preferred language, used if valid and mnemonic matches. + :type mnemonic: Optional[str] + + :return: A tuple containing the language's word indices and the language name. + :rtype: Tuple[Dict[str, int], str] - :return: A tuple containing the word list and the language name if found. - :rtype: Union[str, Tuple[List[str], str]] """ - for language in cls.languages: + language_words_indices: Dict[str, Dict[str, int]] + quality: Dict[str, int] = {} # How many language symbols were matched + for candidate, words_list, words_indices in cls.all_wordslist_indices( wordlist_path=wordlist_path ): + language_words_indices[candidate] = words_indices + quality[candidate] = 0 try: - words_list: List[str] = cls.get_words_list_by_language( - language=language, wordlist_path=wordlist_path - ) - words_list_with_index: dict = { - words_list[i]: i for i in range(len(words_list)) - } + # Check for exact matches and unique abbreviations, ensuring comparison occurs in + # composite "NFKC" normalized characters. for word in mnemonic: + word_composed = unicodedata.normalize( "NFKC", word ) try: - words_list_with_index[word] + words_indices[word_composed] + quality[candidate] += len( word_composed ) except KeyError as ex: raise MnemonicError(f"Unable to find word {word}") from ex - return words_list, language + + if candidate == language: + # All words exactly matched word with or without accents, complete or uniquely + # abbreviated words in the preferred language! We're done - we don't need to + # test further candidate languages. + return words_indices, candidate + + # All words exactly matched words in this candidate language, or some words were + # found to be unique abbreviations of words in the candidate, but it isn't the + # preferred language (or no preferred language was specified). Keep track of its + # quality of match, but carry on testing other candidate languages. except (MnemonicError, ValueError): continue - raise MnemonicError(f"Invalid language for mnemonic '{mnemonic}'") + + # No unambiguous match to any preferred language found. Select the best available. Sort by + # the number of characters matched (more is better - less ambiguous). This is a statistical + # method; it is still dangerous, and we should fail instead of returning a bad guess! + if not quality: + raise MnemonicError(f"Unrecognized language for mnemonic '{mnemonic}'") + + (matches, candidate), *rest = sorted(( (m, c) for c, m in quality.items()), reverse=True ) + if rest and matches == rest[0][0]: + raise MnemonicError(f"Ambiguous language for mnemonic '{mnemonic}'; specify a preferred language") + + return language_words_indices[candidate], candidate + @classmethod def is_valid(cls, mnemonic: Union[str, List[str]], **kwargs) -> bool: @@ -222,13 +379,14 @@ def is_valid_words(cls, words: int) -> bool: @classmethod def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: - """ - Normalizes the given mnemonic by splitting it into a list of words if it is a string. - Resilient to extra whitespace and down-cases uppercase symbols. + """Normalizes the given mnemonic by splitting it into a list of words if it is a string. + Resilient to extra whitespace, compatibility characters such as full-width symbols, + decomposed characters and accents, and down-cases uppercase symbols using NFKC + normalization. Recognizes hex strings (raw entropy), and attempts to normalize them as appropriate for the IMnemonic-derived class using 'from_entropy'. Thus, all IMnemonics can accept either - mnemonic strings, or raw hex-encoded entropy, if they use the IMnemonic.normalize base + mnemonic strings or raw hex-encoded entropy, if they use the IMnemonic.normalize base method in their derived 'decode' and 'is_valid' implementations. This makes sense for most Mnemonics, which produce an repeatable encoding for the same entropy; @@ -238,7 +396,7 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: :param mnemonic: The mnemonic value, which can be a single string of words or a list of words. :type mnemonic: Union[str, List[str]] - :return: A list of words from the mnemonic. + :return: A list of words from the mnemonic, normalized for internal processing. :rtype: List[str] """ @@ -246,5 +404,5 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: if ( len(mnemonic.strip()) * 4 in cls.words_to_entropy_strength.values() and all(c in string.hexdigits for c in mnemonic.strip())): mnemonic: str = cls.from_entropy(mnemonic, language="english") - mnemonic: list = mnemonic.strip().split() - return list(map(lambda _: unicodedata.normalize("NFKD", _.lower()), mnemonic)) + mnemonic: List[str] = mnemonic.strip().split() + return list(unicodedata.normalize("NFKC", word.lower()) for word in mnemonic) diff --git a/hdwallet/mnemonics/monero/mnemonic.py b/hdwallet/mnemonics/monero/mnemonic.py index 8ef17b5f..7046ca24 100644 --- a/hdwallet/mnemonics/monero/mnemonic.py +++ b/hdwallet/mnemonics/monero/mnemonic.py @@ -235,7 +235,7 @@ def encode(cls, entropy: Union[str, bytes], language: str, checksum: bool = Fals ) mnemonic: List[str] = [] - words_list: List[str] = cls.normalize(cls.get_words_list_by_language(language=language)) + words_list: List[str] = cls.get_words_list_by_language(language=language) # NFKC normalized if len(words_list) != cls.words_list_number: raise Error( "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index a4e73432..e6392e3c 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -278,8 +278,7 @@ def prefixed( groups, group_mnemonics ): class SLIP39Mnemonic(IMnemonic): - """ - Implements the SLIP39 standard, allowing the creation of mnemonic phrases for + """Implements the SLIP39 standard, allowing the creation of mnemonic phrases for recovering deterministic keys. Here are available ``SLP39_MNEMONIC_WORDS``: @@ -301,6 +300,31 @@ class SLIP39Mnemonic(IMnemonic): +=======================+======================+ | ENGLISH | english | +-----------------------+----------------------+ + + + For SLIP-39, the language word dictionary is always the same (english) so is ignored (simply + used as a label for the generated SLIP-39), but the rest of the language string specifies + the "dialect" (threshold of groups required/generated, and the threshold of mnemonics + required/generated in each group). + + The default is 1/1: 1/1 (a single group of 1 required, with 1/1 mnemonic required) by supplying + a language without further specific secret recovery or group recovery details: + + "" + "english" + "Any Label" + + The default progression of group mnemonics required/provided is fibonacci over required: + + - A threshold is 1/2 the specified number of groups/mnemonics (rounded up), and + - groups of 1/1, 1/1, 2/4 and 3/6, ... mnemonics + + All of these language specifications produce the same 2/4 group SLIP-39 encoding: + + "Johnson 2/4" + "2: 1/1, 1/1, 2/4, 3/6" + "Johnson 2/4: Home 1/1, Office 1/1, Fam 2/4, Frens 3/6" + """ word_bit_length: int = 10 @@ -323,10 +347,15 @@ class SLIP39Mnemonic(IMnemonic): } def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: + # Record the mnemonics, and the specified language. Computes _words simply for a standard + # single-phrase mnemonic. The language string supplied will super().__init__(mnemonic, **kwargs) - # We know that normalize has already validated _mnemonic's length + # We know that normalize has already validated _mnemonic's length. Compute the per-mnemonic + # words for SLIP-39. self._words, = filter(lambda w: len(self._mnemonic) % w == 0, self.words_list) - + # If a certain tabulation is desired for human readability, remember it. + self._tabulate = kwargs.get("tabulate", False) + @classmethod def name(cls) -> str: """ @@ -349,6 +378,51 @@ def mnemonic(self) -> str: :rtype: str """ + if self._tabulate is not False: + # Output the mnemonics with their language details and desired tabulation. We'll need + # to re-deduce the SLIP-39 secret and group specs from _language. Only if we successfully + # compute the same number of expected mnemonics, will we assume that everything + # is OK (someone hasn't created a SLIP39Mnemonic by hand with a custom _language and _mnemonics), + # and we'll output the re- + ((s_name, (s_thresh, s_size)), groups), = language_parser(language).items() + mnemonic = iter( self._mnemonics ) + try: + group_mnemonics: List[List[str]] =[ + [ + " ".join( next( mnemonic ) for _ in range( self._words )) + for _ in range( g_size ) + ] + for (_g_name, (_g_thresh, g_size)) in groups.items() + ] + except StopIteration: + # Too few mnemonics for SLIP-39 deduced from _language? Ignore and carry on with + # simple mnemonics output. + pass + else: + extras = list(mnemonic) + if not extras: + # Exactly consumed all _mnemonics according to SLIP-39 language spec! Success? + # One final check; all group_mnemonics should have a common prefix. + def common( strings: List[str] ) -> str: + prefix = None + for s in strings: + if common is None: + prefix = s + continue + for i, (cp, cs) in zip(prefix, s): + if cp != cs: + prefix = prefix[:i] + if not prefix: + break + return prefix + + if all( map( common, group_mnemonics )): + return tabulate_slip39( groups, group_mnemonics, columns=self._tabulate ) + + # Either no common prefix in some group; Invalid deduction of group specs + # vs. mnemonics., or left-over Mnemonics! Fall through and render it the + # old-fashioned way... + mnemonic_chunks: Iterable[List[str]] = zip(*[iter(self._mnemonic)] * self._words) mnemonic: Iterable[str] = map(" ".join, mnemonic_chunks) return "\n".join(mnemonic) @@ -358,22 +432,6 @@ def from_words(cls, words: int, language: str) -> str: """Generates a mnemonic phrase from a specified number of words. This method generates a mnemonic phrase based on the specified number of words and language. - For SLIP-39, the language word dictionary is always the same (english) so is ignored (simply - used as a label for the generated SLIP-39), but the rest of the language string specifies - the "dialect" (threshold of groups required/generated, and the threshold of mnemonics - required/generated in each group). - - The default is: - - - A threshold is 1/2 the specified number of groups/mnemonics (rounded up), and - - 4 groups of 1, 1, 4 and 6 mnemonics - - All of these language specifications produce the same 2/4 group SLIP-39 encoding: - - "" - "Johnson" - "2: 1/1, 1/1, 2/4, 3/6" - "Johnson 2/4: Home 1/1, Office 1/1, Fam 2/4, Frens 3/6" :param words: The number of words for the mnemonic phrase. :type words: int diff --git a/hdwallet/seeds/bip39.py b/hdwallet/seeds/bip39.py index c7d84ca9..683f0dc9 100644 --- a/hdwallet/seeds/bip39.py +++ b/hdwallet/seeds/bip39.py @@ -74,9 +74,13 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str if not BIP39Mnemonic.is_valid(mnemonic=mnemonic): raise MnemonicError(f"Invalid {cls.name()} mnemonic words") + # Normalize mnemonic to NFD for seed generation as required by BIP-39 specification + normalized_mnemonic: str = BIP39Mnemonic.normalize_for_seed(mnemonic) + + # Salt normalization should use NFKD as per BIP-39 specification salt: str = unicodedata.normalize("NFKD", ( (cls.seed_salt_modifier + passphrase) if passphrase else cls.seed_salt_modifier )) return bytes_to_string(pbkdf2_hmac_sha512( - password=mnemonic, salt=salt, iteration_num=cls.seed_pbkdf2_rounds + password=normalized_mnemonic, salt=salt, iteration_num=cls.seed_pbkdf2_rounds )) diff --git a/requirements.txt b/requirements.txt index d2f5c373..1e3555c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ pynacl>=1.5.0,<2 base58>=2.1.1,<3 cbor2>=5.6.1,<6 shamir-mnemonic-slip39>=0.4,<0.5 +tabulate>=0.9.0,<1 diff --git a/requirements/cli.txt b/requirements/cli.txt index 7b964544..117c611d 100644 --- a/requirements/cli.txt +++ b/requirements/cli.txt @@ -1,4 +1,3 @@ click>=8.1.7,<9 click-aliases>=1.0.5,<2 -tabulate>=0.9.0,<1 bip38>=1.4.1,<2 diff --git a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py index fe651c05..45203998 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py @@ -60,6 +60,7 @@ def test_slip39_language(): }, } + def test_slip39_mnemonics(): # Ensure our prefix and whitespace handling works correctly @@ -92,9 +93,9 @@ def test_slip39_mnemonics(): assert slip39.mnemonic() == mnemonic for mnemonic in [ - "curly agency academic academic academic boring radar cluster domestic ticket fumes remove velvet fluff video crazy chest average script universe exhaust remind helpful lamp declare garlic repeat unknown bucket adorn sled adult triumph source divorce premium genre glimpse level listen ancestor wildlife writing document wrist judicial medical detect frost leaves language jerky increase glasses extra alto burden iris swing", - "trend cleanup acrobat easy acid military timber boundary museum dictate argue always grasp bundle welcome silent campus exhaust snake magazine kitchen surface unfold theory adequate gasoline exotic counter fantasy magazine slow mailman metric thumb listen ruler elite mansion diet hybrid withdraw swing makeup repeat glasses density express ting estimate climate scholar loyalty unfold bumpy ecology briefing much fiscal mental\ntrend cleanup beard easy acne extra profile window craft custody owner plot inherit injury starting iris talent curious squeeze retreat density decision hush rainbow extra grumpy humidity income should spray elevator drove large source game pajamas sprinkle dining security class adapt credit therapy verify realize retailer scatter suitable stick hearing lecture mountain dragon talent medal decision equip cleanup aircraft", - "salon email acrobat romp acid lunar rival view daughter exchange privacy pickup moisture forbid welcome amount estimate therapy sled theory says member scroll sister smell erode scene tension glance laden ting cricket apart senior legend transfer describe crowd exceed saver lilac episode cluster pipeline sniff window loyalty manual behavior raspy problem fraction story playoff scroll aunt benefit element execute\nsalon email beard romp acquire vocal plan aviation nervous package unhappy often goat forward closet material fortune fitness wireless terminal slap resident aunt artist source cover perfect grant military ruin taught depend criminal theater decision standard salary priority equation license prisoner rhyme indicate academic shaft express kernel airport tolerate market owner erode dance orange beaver distance smug plunge level\nsalon email ceramic roster academic spark starting says phantom tension saver erode ugly smoking crazy screw pumps display funding fortune mixture ancestor industry glad paces junk laden timber hunting secret program ruin gather clogs legal sugar adjust check crazy genuine predator national swimming twice admit desert system sidewalk check class spelling early morning liberty grief election antenna merchant adjust\nsalon email ceramic scared acid cultural object wildlife percent include wealthy geology capture lift evidence envy identify game guilt curly garbage reaction early scatter practice metric mild earth subject axis verdict juice sled dominant ranked blimp sympathy credit example typical float prisoner ting paces husband adequate amuse display worthy amuse depict civil learn modify lecture mother paid evil stadium\nsalon email ceramic shadow acquire critical ugly desire piece romp piece olympic benefit cargo forbid superior credit username library usher beyond include verify pipeline volume pistol ajar mild carbon acrobat receiver decrease champion calcium flea email picture funding tracks junior fishing thorn regret lily tofu decent romp hazard loud cards peaceful alien retreat single pregnant unfold trial wrist jury\nsalon email ceramic sister acne spirit parking aquatic phrase fact order racism tendency example disaster finance trip multiple ranked lobe tackle smirk regular auction satoshi elephant traveler estimate practice sprinkle true making manual adjust herald mama jacket fishing lecture volume phantom symbolic liberty usher moment alcohol born nervous flip desert element budget pink switch envy discuss laden check promise\nsalon email decision round acquire voting damage briefing emphasis parking airport nylon umbrella coding fake cylinder chubby bolt superior client shame museum reward domain briefing forget guilt group leaf teacher that remind blind judicial soul library dismiss guard provide smoking robin blue focus relate tricycle flexible meaning painting venture trip manager stay flexible rebuild group elephant papa dismiss activity\nsalon email decision scatter acid idle veteran knife thorn theory remember volume cluster writing drove process staff usual sprinkle observe sympathy says birthday lunar leaves salary belong license submit anxiety award spray body victim domestic solution decent geology huge preach human scared desktop email frost verify says predator debris peasant burden swing owner safari reaction broken glimpse jacket deal\nsalon email decision shaft academic breathe mental capital midst guest tracks bolt twin change usual rescue profile taxi paces penalty vitamins emphasis story acquire exhaust salt quantity junction shame midst saver peanut acquire trash duke spend remember predator miracle vintage rich multiple story inmate depend example together blimp coding depart acid diminish petition sister mountain explain thumb density kidney\nsalon email decision skin acne owner finance kernel deal crazy fortune kernel cause warn ordinary document forward alto mixed burning theater axis hybrid review squeeze force shelter owner minister jump darkness smith advance greatest stadium listen prune prisoner exceed medal hospital else race lying liquid tolerate preach capture therapy junction method demand glasses relate emerald blind club income exceed\nsalon email decision snake acne repair sidewalk window video knit resident alien window weapon chubby pacific segment artwork nuclear erode thorn replace wits snapshot founder shaped quiet spray sled depend decent cage income pecan estimate purchase frequent trash chew luxury glimpse category move pipeline scout snake source entrance laundry skunk gravity briefing ancestor hormone security husky snake nylon prospect\nsalon email decision spider academic dramatic axis overall finger early alive health decent ceiling explain capture deploy trip mother viral valid unwrap filter holiday saver fake sharp decorate mustang stay survive hybrid hybrid cowboy peanut that findings umbrella worthy venture quick various watch filter impact jury paid elevator retreat literary viral capacity skin bumpy blue criminal behavior surface legal", + "curly agency academic academic academic boring radar cluster domestic ticket fumes remove velvet fluff video crazy chest average script universe exhaust remind helpful lamp declare garlic repeat unknown bucket adorn sled adult triumph source divorce premium genre glimpse level listen ancestor wildlife writing document wrist judicial medical detect frost leaves language jerky increase glasses extra alto burden iris swing", + "trend cleanup acrobat easy acid military timber boundary museum dictate argue always grasp bundle welcome silent campus exhaust snake magazine kitchen surface unfold theory adequate gasoline exotic counter fantasy magazine slow mailman metric thumb listen ruler elite mansion diet hybrid withdraw swing makeup repeat glasses density express ting estimate climate scholar loyalty unfold bumpy ecology briefing much fiscal mental\ntrend cleanup beard easy acne extra profile window craft custody owner plot inherit injury starting iris talent curious squeeze retreat density decision hush rainbow extra grumpy humidity income should spray elevator drove large source game pajamas sprinkle dining security class adapt credit therapy verify realize retailer scatter suitable stick hearing lecture mountain dragon talent medal decision equip cleanup aircraft", + "salon email acrobat romp acid lunar rival view daughter exchange privacy pickup moisture forbid welcome amount estimate therapy sled theory says member scroll sister smell erode scene tension glance laden ting cricket apart senior legend transfer describe crowd exceed saver lilac episode cluster pipeline sniff window loyalty manual behavior raspy problem fraction story playoff scroll aunt benefit element execute\nsalon email beard romp acquire vocal plan aviation nervous package unhappy often goat forward closet material fortune fitness wireless terminal slap resident aunt artist source cover perfect grant military ruin taught depend criminal theater decision standard salary priority equation license prisoner rhyme indicate academic shaft express kernel airport tolerate market owner erode dance orange beaver distance smug plunge level\nsalon email ceramic roster academic spark starting says phantom tension saver erode ugly smoking crazy screw pumps display funding fortune mixture ancestor industry glad paces junk laden timber hunting secret program ruin gather clogs legal sugar adjust check crazy genuine predator national swimming twice admit desert system sidewalk check class spelling early morning liberty grief election antenna merchant adjust\nsalon email ceramic scared acid cultural object wildlife percent include wealthy geology capture lift evidence envy identify game guilt curly garbage reaction early scatter practice metric mild earth subject axis verdict juice sled dominant ranked blimp sympathy credit example typical float prisoner ting paces husband adequate amuse display worthy amuse depict civil learn modify lecture mother paid evil stadium\nsalon email ceramic shadow acquire critical ugly desire piece romp piece olympic benefit cargo forbid superior credit username library usher beyond include verify pipeline volume pistol ajar mild carbon acrobat receiver decrease champion calcium flea email picture funding tracks junior fishing thorn regret lily tofu decent romp hazard loud cards peaceful alien retreat single pregnant unfold trial wrist jury\nsalon email ceramic sister acne spirit parking aquatic phrase fact order racism tendency example disaster finance trip multiple ranked lobe tackle smirk regular auction satoshi elephant traveler estimate practice sprinkle true making manual adjust herald mama jacket fishing lecture volume phantom symbolic liberty usher moment alcohol born nervous flip desert element budget pink switch envy discuss laden check promise\nsalon email decision round acquire voting damage briefing emphasis parking airport nylon umbrella coding fake cylinder chubby bolt superior client shame museum reward domain briefing forget guilt group leaf teacher that remind blind judicial soul library dismiss guard provide smoking robin blue focus relate tricycle flexible meaning painting venture trip manager stay flexible rebuild group elephant papa dismiss activity\nsalon email decision scatter acid idle veteran knife thorn theory remember volume cluster writing drove process staff usual sprinkle observe sympathy says birthday lunar leaves salary belong license submit anxiety award spray body victim domestic solution decent geology huge preach human scared desktop email frost verify says predator debris peasant burden swing owner safari reaction broken glimpse jacket deal\nsalon email decision shaft academic breathe mental capital midst guest tracks bolt twin change usual rescue profile taxi paces penalty vitamins emphasis story acquire exhaust salt quantity junction shame midst saver peanut acquire trash duke spend remember predator miracle vintage rich multiple story inmate depend example together blimp coding depart acid diminish petition sister mountain explain thumb density kidney\nsalon email decision skin acne owner finance kernel deal crazy fortune kernel cause warn ordinary document forward alto mixed burning theater axis hybrid review squeeze force shelter owner minister jump darkness smith advance greatest stadium listen prune prisoner exceed medal hospital else race lying liquid tolerate preach capture therapy junction method demand glasses relate emerald blind club income exceed\nsalon email decision snake acne repair sidewalk window video knit resident alien window weapon chubby pacific segment artwork nuclear erode thorn replace wits snapshot founder shaped quiet spray sled depend decent cage income pecan estimate purchase frequent trash chew luxury glimpse category move pipeline scout snake source entrance laundry skunk gravity briefing ancestor hormone security husky snake nylon prospect\nsalon email decision spider academic dramatic axis overall finger early alive health decent ceiling explain capture deploy trip mother viral valid unwrap filter holiday saver fake sharp decorate mustang stay survive hybrid hybrid cowboy peanut that findings umbrella worthy venture quick various watch filter impact jury paid elevator retreat literary viral capacity skin bumpy blue criminal behavior surface legal", ]: assert SLIP39Mnemonic.is_valid(mnemonic) slip39 = SLIP39Mnemonic(mnemonic) @@ -132,6 +133,19 @@ def test_slip39_mnemonics(): ) +def test_slip39_init(): + """Details of the SLIP-39 specifications' 'language' and output 'tabulate' value must be kept, + so .mnemonic() reflects them. + + """ + for entropy in [ + "ff" * (128//8), + "ff" * (256//8), + "ff" * (512//8), + ]: + pass + + class substitute( contextlib.ContextDecorator ): """The SLIP-39 standard includes random data in portions of the as share. Replace the random function during testing to get determinism in resultant nmenomics. diff --git a/tests/test_bip39_cross_language.py b/tests/test_bip39_cross_language.py new file mode 100644 index 00000000..7c3addbb --- /dev/null +++ b/tests/test_bip39_cross_language.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 + +import os +import random +import unicodedata +from typing import List, Set + +import pytest + +from hdwallet.mnemonics.bip39 import BIP39Mnemonic + + +class TestBIP39CrossLanguage: + """Test BIP39 mnemonics that work in both English and French languages. + + This test explores the theoretical possibility of mnemonics that can be validly + decoded in multiple languages. About 2% (100/2048) of words are common between + the English and French BIP-39 wordlists. + + For randomly generated entropy uses only common words: + - Probability for a 12-word mnemonic: (100/2048)^12*1/16 ≈ 1.15*10^-17 + - Probability for a 24-word mnemonic: (100/2048)^24*1/256 ≈ 1.32x10^-34 + + Most wallets allow abbreviations; only the first few characters of the word need to be entered: + the words are guaranteed to be unique after entering at least 4 letters (including the word end + symbol; eg. 'run' 'runway, and 'sea' 'search' 'season' 'seat'). + + If we include full words in one language that are abbreviations in the other, the probabilities + increase: + + + + These probabilities are astronomically small, so naturally occurring mnemonics + will essentially never be composed entirely of common words. + + This test deliberately constructs mnemonics using only common words, + then tests what fraction pass checksum validation in both languages: + - For 12-word mnemonics: ~1/16 (6.25%) due to 4-bit checksum + - For 24-word mnemonics: ~1/256 (0.39%) due to 8-bit checksum + + This demonstrates the theoretical cross-language compatibility while showing + why it's not a practical security concern for real-world usage. + + For details about BIP-39 word list selection for various languages, see: + + https://github.com/bitcoin/bips/blob/master/bip-0039/bip-0039-wordlists.md + + Particularly interesting for abbreviations is the fact that any letter with an accent is + considered equal to the same letter without the accent for entry and word selection. + + """ + + @classmethod + def _load_language_wordlist(cls, language: str) -> tuple[set, list]: + """Load wordlist for a given language and compute abbreviations. + + Args: + language: Language name (e.g., 'english', 'french'), NFKC normalized to combine characters and accents + + Returns: + Tuple of (words_set, abbreviations_list) + """ + wordlist_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + f"hdwallet/mnemonics/bip39/wordlist/{language}.txt" + ) + with open(wordlist_path, "r", encoding="utf-8") as f: + words = set(unicodedata.normalize("NFKC", word.strip()) for word in f.readlines() if word.strip()) + + # All words are unique in 3 or 4 letters + abbrevs = [ + w[:3] if (w[:3] in words or len(set(aw.startswith(w[:3]) for aw in words)) == 1) else w[:4] + for w in words + ] + + # Debug print for abbreviation conflicts + conflicts = { + ab: set(w for w in words if w.startswith(ab)) + for ab in abbrevs + if ab not in words and len(set(w for w in words if w.startswith(ab))) > 1 + } + if conflicts: + print(f"{language.capitalize()} abbreviation conflicts: {conflicts}") + + assert all(ab in words or len(set(w for w in words if w.startswith(ab))) == 1 for ab in abbrevs) + + return words, abbrevs + + @classmethod + def setup_class(cls, languages: list[str] = None): + """Load wordlists and find common words between specified languages. + + Args: + languages: List of language names to load (defaults to ['english', 'french']) + """ + if languages is None: + languages = ['english', 'french'] + + if len(languages) < 2: + raise ValueError("At least 2 languages are required for cross-language testing") + + # Load all specified languages + language_data = {} + for language, words, words_indices in BIP39Mnemonic.all_wordslist_indices(): + language_data[language] = {'words': words, 'abbrevs': words_indices} + if language not in languages: + continue + + # Set class attributes for backward compatibility + setattr(cls, f"{lang}_words", language_data[language]['words']) + setattr(cls, f"{lang}_abbrevs", language_data[language]['abbrevs']) + + # Find common words across all languages + all_word_sets = [data['words'] for data in language_data.values()] + all_abbrev_lists = [data['abbrevs'] for data in language_data.values()] + + cls.common_words = list(set.intersection(*all_word_sets)) + cls.common_abbrevs = list(set.intersection(*[set(abbrevs) for abbrevs in all_abbrev_lists])) + + # Print statistics + for lang, data in language_data.items(): + print(f"{lang.capitalize()} words: {len(data['words'])}") + + print(f"Common words found: {len(cls.common_words)}") + print(f"First 20 common words: {cls.common_words[:20]}") + print(f"Common abbrevs found: {len(cls.common_abbrevs)}") + print(f"First 20 common abbrevs: {cls.common_abbrevs[:20]}") + + def create_random_mnemonic_from_common_words(self, word_count: int) -> str: + """Create a random mnemonic using only common words.""" + if len(self.common_words) < word_count: + raise ValueError(f"Not enough common words ({len(self.common_words)}) to create {word_count}-word mnemonic") + + selected_words = random.choices(self.common_words, k=word_count) + return selected_words + return " ".join(selected_words) + + def test_common_words_exist(self): + """Test that there are common words between English and French wordlists.""" + assert len(self.common_words) > 0, "No common words found between English and French wordlists" + + def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_attempts=1000): + """Test N-word mnemonics that work in both English and French.""" + successful_both_languages: List[List[str]] = [] + + for _ in range(total_attempts): + try: + # Generate a random N-word mnemonic from common words + mnemonic = self.create_random_mnemonic_from_common_words(words) + + # Try to decode as both English and French - both must succeed (pass checksum) + # Note: We expect different entropy values since words have different indices + entropy_english = BIP39Mnemonic.decode(mnemonic, words_list=self.english_words) + entropy_french = BIP39Mnemonic.decode(mnemonic, words_list=self.french_words) + + # If both decode successfully, the mnemonic is valid in both languages + successful_both_languages.append(mnemonic) + print(f"{words}-word common mnemonics {' '.join(mnemonic)!r}") + + except Exception as exc: + # Skip invalid mnemonics (e.g., checksum failures) + continue + + success_rate = len(successful_both_languages) / total_attempts + + print(f"{words}-word mnemonics: {len(successful_both_languages)}/{total_attempts} successful ({success_rate:.4f})") + print(f"Expected rate: ~{expected_rate:.4f}") + + # Assert we found at least some successful mnemonics + assert success_rate > 0, f"No {words}-word mnemonics worked in both languages" + + # The success rate should be roughly around the expected rate, but due to + # randomness and limited common words, we'll accept a broader range + tolerance = 0.5 # 50% tolerance due to statistical variance + assert expected_rate * (1 - tolerance) < success_rate < expected_rate * (1 + tolerance), \ + f"Success rate {success_rate:.4f} not in expected range around {expected_rate:.4f}" + return successful_both_languages + + def test_cross_language_12_word_mnemonics(self): + """Test 12-word mnemonics that work in both English and French.""" + candidates = self.dual_language_N_word_mnemonics(words=12, expected_rate=1/16, total_attempts=1000) + + + + def test_cross_language_24_word_mnemonics(self): + """Test 24-word mnemonics that work in both English and French.""" + candidates = self.dual_language_N_word_mnemonics(words=24, expected_rate=1/256, total_attempts=5000) + + def test_wordlist_properties(self): + """Test basic properties of the wordlists.""" + # Verify wordlist sizes + assert len(self.english_words) == 2048, f"English wordlist should have 2048 words, got {len(self.english_words)}" + assert len(self.french_words) == 2048, f"French wordlist should have 2048 words, got {len(self.french_words)}" + + # Verify no duplicates within each wordlist + assert len(set(self.english_words)) == len(self.english_words), "English wordlist contains duplicates" + assert len(set(self.french_words)) == len(self.french_words), "French wordlist contains duplicates" + + # Verify common words list properties + assert len(self.common_words) > 0, "No common words found" + assert len(set(self.common_words)) == len(self.common_words), "Common words list contains duplicates" + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_bip39_normalization.py b/tests/test_bip39_normalization.py new file mode 100644 index 00000000..b593aac1 --- /dev/null +++ b/tests/test_bip39_normalization.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + +import unicodedata +import pytest + +from hdwallet.mnemonics.bip39 import BIP39Mnemonic +from hdwallet.seeds.bip39 import BIP39Seed + + +class TestBIP39Normalization: + """Test BIP-39 normalization implementation fixes. + + This test validates that the normalization changes work correctly: + 1. User input uses NFKC -> NFC normalization (handles compatibility characters) + 2. Internal processing uses NFC normalization (consistent word comparisons) + 3. Seed generation uses NFD normalization (BIP-39 specification requirement) + """ + + @classmethod + def setup_class(cls): + """Set up test cases with different Unicode representations.""" + + # Test mnemonic with accented characters in different Unicode forms + cls.test_mnemonics = { + # French mnemonic with é in different forms + 'composed': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon café', # é as U+00E9 + 'decomposed': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon cafe\u0301', # e + U+0301 + + # Spanish mnemonic with ñ in different forms + 'composed_spanish': 'ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco niño', # ñ as U+00F1 + 'decomposed_spanish': 'ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco nin\u0303o', # n + U+0303 + } + + # User input scenarios with compatibility characters + cls.compatibility_scenarios = { + # Fullwidth characters (common with Asian keyboards) + 'fullwidth': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon café', + # Ligature characters + 'ligature': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon cafie', # contains fi ligature + # Roman numeral (though unlikely in real mnemonics) + 'roman': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon cafeⅠ', + } + + def test_user_input_normalization(self): + """Test that user input normalization handles compatibility characters correctly.""" + + # Test NFKC -> NFC normalization for user input + test_cases = [ + # Fullwidth input should normalize to regular characters + ('abandon', 'abandon'), + # Ligature input should decompose + ('file', 'file'), # fi ligature -> fi + # Mixed case should be lowercased + ('ABANDON', 'abandon'), + ] + + for input_word, expected in test_cases: + result = BIP39Mnemonic.normalize_user_input([input_word]) + assert result == [expected], f"Failed to normalize '{input_word}' to '{expected}', got {result}" + + def test_wordlist_normalization(self): + """Test that wordlists are properly normalized to NFC for internal processing.""" + + # Load French wordlist (contains accented characters) + french_words = BIP39Mnemonic.get_words_list_by_language('french') + + # Check that all words are in NFC form + for word in french_words[:10]: # Test first 10 words + nfc_form = unicodedata.normalize('NFC', word) + assert word == nfc_form, f"Word '{word}' is not in NFC form, got '{nfc_form}'" + + # Check specific accented words + accented_words = [word for word in french_words if any(ord(c) > 127 for c in word)][:5] + for word in accented_words: + # Verify it's in NFC form (shorter than NFD due to composed characters) + nfd_form = unicodedata.normalize('NFD', word) + assert len(word) < len(nfd_form), f"Word '{word}' doesn't appear to be in NFC form" + + def test_mnemonic_validation_consistency(self): + """Test that mnemonics with different Unicode representations validate consistently.""" + + # These should all represent the same semantic mnemonic + composed_mnemonic = self.test_mnemonics['composed'] + decomposed_mnemonic = self.test_mnemonics['decomposed'] + + # Both should be valid (after normalization) + assert BIP39Mnemonic.is_valid(composed_mnemonic), "Composed mnemonic should be valid" + assert BIP39Mnemonic.is_valid(decomposed_mnemonic), "Decomposed mnemonic should be valid" + + # Both should normalize to the same internal representation + composed_normalized = BIP39Mnemonic.normalize(composed_mnemonic) + decomposed_normalized = BIP39Mnemonic.normalize(decomposed_mnemonic) + assert composed_normalized == decomposed_normalized, "Different Unicode forms should normalize to same result" + + def test_seed_generation_normalization(self): + """Test that seed generation uses NFD normalization as required by BIP-39.""" + + # Use a known valid mnemonic + test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + # Verify the mnemonic is valid + assert BIP39Mnemonic.is_valid(test_mnemonic), "Test mnemonic should be valid" + + # Test normalize_for_seed method + normalized_for_seed = BIP39Mnemonic.normalize_for_seed(test_mnemonic) + + # Should be in NFD form + nfd_form = unicodedata.normalize('NFD', test_mnemonic) + assert normalized_for_seed == nfd_form, "normalize_for_seed should return NFD form" + + # Generate seed to verify it works + seed = BIP39Seed.from_mnemonic(test_mnemonic) + assert len(seed) == 128, "BIP39 seed should be 128 hex characters (64 bytes)" + + def test_seed_generation_with_accents(self): + """Test seed generation with accented characters uses proper NFD normalization.""" + + # Test with French mnemonic containing accents + # This is a crafted example - real French mnemonics would need proper checksum + french_test = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon café" + + if BIP39Mnemonic.is_valid(french_test): + # Test that different Unicode representations produce the same seed + composed = french_test # é as single codepoint + decomposed = french_test.replace('café', 'cafe\u0301') # e + combining accent + + seed1 = BIP39Seed.from_mnemonic(composed) + seed2 = BIP39Seed.from_mnemonic(decomposed) + + assert seed1 == seed2, "Different Unicode representations should produce the same seed" + + def test_compatibility_character_handling(self): + """Test that compatibility characters in user input are handled correctly.""" + + # Test fullwidth characters (common with Asian keyboards) + fullwidth_input = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + normal_input = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + # Normalize both + fullwidth_normalized = BIP39Mnemonic.normalize(fullwidth_input) + normal_normalized = BIP39Mnemonic.normalize(normal_input) + + # Should produce the same result + assert fullwidth_normalized == normal_normalized, "Fullwidth input should normalize to same result as normal input" + + # Both should be valid + assert BIP39Mnemonic.is_valid(fullwidth_input), "Fullwidth input should be valid after normalization" + assert BIP39Mnemonic.is_valid(normal_input), "Normal input should be valid" + + def test_normalization_methods_consistency(self): + """Test that different normalization methods work consistently.""" + + test_input = "abandon café" # Mix of ASCII and accented + + # Test normalize vs normalize_user_input + normalized = BIP39Mnemonic.normalize(test_input) + user_normalized = BIP39Mnemonic.normalize_user_input(test_input) + + assert normalized == user_normalized, "normalize() and normalize_user_input() should produce same result" + + # Test normalize_for_seed + seed_normalized = BIP39Mnemonic.normalize_for_seed(test_input) + + # Should be different (NFD vs NFC) + input_nfc = unicodedata.normalize('NFC', test_input) + input_nfd = unicodedata.normalize('NFD', test_input) + + assert seed_normalized == input_nfd, "normalize_for_seed should return NFD form" + assert " ".join(normalized) != seed_normalized, "Internal normalization should differ from seed normalization" + + def test_edge_cases(self): + """Test edge cases and error conditions.""" + + # Empty string + assert BIP39Mnemonic.normalize("") == [] + assert BIP39Mnemonic.normalize([]) == [] + + # Single word + single_word = BIP39Mnemonic.normalize("abandon") + assert single_word == ["abandon"] + + # Mixed case with accents + mixed_case = BIP39Mnemonic.normalize("CAFÉ") + assert mixed_case == ["café"] + + # Extra whitespace + whitespace_test = BIP39Mnemonic.normalize(" abandon about ") + assert whitespace_test == ["abandon", "about"] + + def test_backwards_compatibility(self): + """Test that changes don't break existing functionality.""" + + # Test standard English mnemonic (no accents, should work as before) + english_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + # Should be valid + assert BIP39Mnemonic.is_valid(english_mnemonic) + + # Should generate consistent seed + seed = BIP39Seed.from_mnemonic(english_mnemonic) + assert isinstance(seed, str) + assert len(seed) == 128 + + # Normalized forms should be consistent + normalized = BIP39Mnemonic.normalize(english_mnemonic) + assert all(isinstance(word, str) for word in normalized) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/test_unicode_normalization.py b/tests/test_unicode_normalization.py new file mode 100644 index 00000000..b6d4d940 --- /dev/null +++ b/tests/test_unicode_normalization.py @@ -0,0 +1,1166 @@ +#!/usr/bin/env python3 + +import os +import unicodedata +from dataclasses import dataclass +from typing import Dict, List, Tuple, Set + +import pytest + + +@dataclass +class CharacterInfo: + """Information about a single Unicode character.""" + char: str + ord: int + hex: str + category: str + name: str + script: str + + +@dataclass +class UnicodeAnalysis: + """Analysis results for a Unicode text string.""" + text: str + label: str + length: int + categories: List[str] + category_counts: Dict[str, int] + scripts: Set[str] + character_details: List[CharacterInfo] + + +class TestUnicodeNormalization: + """Test Unicode normalization effects on BIP-39 mnemonic words. + + This test validates our understanding of how unicodedata.normalize works + with the four normalization forms (NFC, NFD, NFKC, NFKD) on actual + BIP-39 wordlist entries that contain Unicode characters with diacritics. + + Key normalization forms: + - NFC (Canonical Decomposition + Canonical Composition): é = é (U+00E9) + - NFD (Canonical Decomposition): é = e + ´ (U+0065 + U+0301) + - NFKC (Compatibility Decomposition + Canonical Composition): similar to NFC but more aggressive + - NFKD (Compatibility Decomposition): similar to NFD but more aggressive + + BIP-39 specifies that mnemonics should use NFC normalization. + + === KEY FINDINGS CONFIRMED BY THIS TEST === + + 1. BIP-39 wordlists use NFD (decomposed) form: + - French/Spanish wordlists store accented characters as base + combining diacritics + - Example: "café" is stored as c-a-f-e-´ (5 codepoints) not c-a-f-é (4 codepoints) + - Confirmed in: test_bip39_word_normalization_consistency() with french.txt/spanish.txt + - Evidence: "Is NFC normalized: False" for all accented words from wordlists + + 2. Normalization forms work as expected: + - NFC combines decomposed → composed (e + ´ → é, reduces length) + - NFD decomposes composed → base + combining (é → e + ´, increases length) + - Confirmed in: test_normalization_understanding() and test_manual_normalization_cases() + + 3. String length changes with normalization: + - NFD form is longer due to separate combining characters + - NFC form is shorter due to composed characters + - Confirmed in: test_bip39_word_normalization_consistency() output shows different lengths + + 4. Case is preserved through normalization: + - "café" vs "CAFÉ" maintain their case after all normalization forms + - Confirmed in: test_case_sensitivity_with_normalization() + + 5. Equivalence classes work correctly: + - Different representations (composed vs decomposed) normalize to same forms + - Confirmed in: test_normalization_equivalence_classes() + + 6. NFKC = NFC for BIP-39 words (no compatibility characters): + - All BIP-39 words pass the assertion that NFC == NFKC + - Confirmed in: test_bip39_word_normalization_consistency() assertion + """ + + @classmethod + def setup_class(cls): + """Load sample words with Unicode characters from BIP-39 wordlists.""" + base_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hdwallet/mnemonics/bip39/wordlist") + + cls.test_words = {} + + # Load French words with accents from hdwallet/mnemonics/bip39/wordlist/french.txt + # These words demonstrate KEY FINDING #1: BIP-39 uses NFD (decomposed) form + # French has ~366 words with accents stored as base letter + combining diacritics + french_path = os.path.join(base_path, "french.txt") + with open(french_path, "r", encoding="utf-8") as f: + french_words = [line.strip() for line in f if line.strip()] + cls.test_words['french'] = [ + word for word in french_words + if any(ord(c) > 127 for c in word) # Filter for non-ASCII (accented) words + ][:10] # Take first 10 for testing + + # Load Spanish words with accents from hdwallet/mnemonics/bip39/wordlist/spanish.txt + # Spanish also has ~334 words with accents, confirming NFD usage across languages + spanish_path = os.path.join(base_path, "spanish.txt") + with open(spanish_path, "r", encoding="utf-8") as f: + spanish_words = [line.strip() for line in f if line.strip()] + cls.test_words['spanish'] = [ + word for word in spanish_words + if any(ord(c) > 127 for c in word) # Filter for non-ASCII (accented) words + ][:10] # Take first 10 for testing + + # Manual test cases to understand specific normalization behaviors + # These demonstrate KEY FINDINGS #2, #4, #5 about normalization equivalence + cls.manual_test_cases = [ + # Various ways to represent "é" - demonstrates equivalence classes + "café", # é as single codepoint U+00E9 (NFC form) + "cafe\u0301", # e + combining acute accent U+0301 (NFD form) + # Various ways to represent "ñ" - demonstrates equivalence classes + "niño", # ñ as single codepoint U+00F1 (NFC form) + "nin\u0303o", # n + combining tilde U+0303 (NFD form) + # Greek letters (no combining characters, should be unchanged) + "αβγ", # Greek letters - tests that non-Latin scripts work correctly + # Mixed case - demonstrates KEY FINDING #4 about case preservation + "Café", # Mixed case with composed accent + "CAFÉ", # Upper case with composed accent + ] + + # Compatibility character test cases - these demonstrate NFKC/NFKD differences + # These characters might appear in user input but not in BIP-39 wordlists + cls.compatibility_test_cases = [ + # Roman numerals (compatibility characters) + "Ⅰ", # U+2160 ROMAN NUMERAL ONE → "I" under NFKC/NFKD + "Ⅱ", # U+2161 ROMAN NUMERAL TWO → "II" under NFKC/NFKD + "ⅰ", # U+2170 SMALL ROMAN NUMERAL ONE → "i" under NFKC/NFKD + # Circled numbers (compatibility characters) + "①", # U+2460 CIRCLED DIGIT ONE → "1" under NFKC/NFKD + "②", # U+2461 CIRCLED DIGIT TWO → "2" under NFKC/NFKD + # Fullwidth characters (compatibility characters common in Asian input) + "abc", # U+FF41, U+FF42, U+FF43 → "abc" under NFKC/NFKD + "ABC", # U+FF21, U+FF22, U+FF23 → "ABC" under NFKC/NFKD + "123", # U+FF11, U+FF12, U+FF13 → "123" under NFKC/NFKD + # Superscript/subscript (compatibility characters) + "café²", # U+00B2 SUPERSCRIPT TWO → "café2" under NFKC/NFKD + "H₂O", # H + U+2082 SUBSCRIPT TWO + O → "H2O" under NFKC/NFKD + # Ligatures (compatibility characters) + "file", # U+FB01 LATIN SMALL LIGATURE FI → "file" under NFKC/NFKD + "ff", # U+FB00 LATIN SMALL LIGATURE FF → "ff" under NFKC/NFKD + # Mixed compatibility with BIP-39 relevant words + "café", # Fullwidth + normal é → "café" under NFKC/NFKD + ] + + def analyze_unicode_string(self, text: str) -> Dict[str, any]: + """Analyze a Unicode string and return detailed information.""" + return { + 'original': text, + 'length': len(text), + 'codepoints': [hex(ord(c)) for c in text], + 'char_names': [unicodedata.name(c, f'UNKNOWN-{ord(c):04X}') for c in text], + 'nfc': unicodedata.normalize('NFC', text), + 'nfd': unicodedata.normalize('NFD', text), + 'nfkc': unicodedata.normalize('NFKC', text), + 'nfkd': unicodedata.normalize('NFKD', text), + } + + def test_normalization_understanding(self): + """Test basic understanding of Unicode normalization forms.""" + # Test composed vs decomposed é + composed_e = "é" # U+00E9 + decomposed_e = "e\u0301" # U+0065 + U+0301 + + # NFC should give us the composed form + assert unicodedata.normalize('NFC', composed_e) == composed_e + assert unicodedata.normalize('NFC', decomposed_e) == composed_e + + # NFD should give us the decomposed form + assert unicodedata.normalize('NFD', composed_e) == decomposed_e + assert unicodedata.normalize('NFD', decomposed_e) == decomposed_e + + # They should be different in their raw forms but equal after normalization + assert composed_e != decomposed_e # Different byte representations + assert unicodedata.normalize('NFC', composed_e) == unicodedata.normalize('NFC', decomposed_e) + + def test_bip39_word_normalization_consistency(self): + """Test that BIP-39 words are consistently normalized. + + CONFIRMS KEY FINDINGS #1, #3, #6: + - Shows BIP-39 wordlists use NFD form ("Is NFC normalized: False") + - Demonstrates length differences between NFC/NFD forms + - Verifies NFC == NFKC (no compatibility characters in BIP-39) + - Tests actual words from french.txt and spanish.txt wordlists + """ + for language, words in self.test_words.items(): + print(f"\n=== Testing {language} words ===") + + for word in words: + analysis = self.analyze_unicode_string(word) + + print(f"\nWord: {word}") + print(f" Codepoints: {analysis['codepoints']}") + print(f" NFC: '{analysis['nfc']}' (len={len(analysis['nfc'])})") + print(f" NFD: '{analysis['nfd']}' (len={len(analysis['nfd'])})") + print(f" NFKC: '{analysis['nfkc']}' (len={len(analysis['nfkc'])})") + print(f" NFKD: '{analysis['nfkd']}' (len={len(analysis['nfkd'])})") + + # KEY FINDING #1: BIP-39 words are stored in NFD (decomposed) form + # This will show "False" for all accented words, proving they use NFD + is_nfc_normalized = (word == analysis['nfc']) + print(f" Is NFC normalized: {is_nfc_normalized}") + + # All forms should produce valid strings + assert isinstance(analysis['nfc'], str) + assert isinstance(analysis['nfd'], str) + assert isinstance(analysis['nfkc'], str) + assert isinstance(analysis['nfkd'], str) + + # KEY FINDING #6: NFC == NFKC for BIP-39 words (no compatibility characters) + assert analysis['nfc'] == analysis['nfkc'], f"NFC != NFKC for {word}" + + def test_manual_normalization_cases(self): + """Test specific normalization cases to understand behavior. + + CONFIRMS KEY FINDINGS #2, #5: + - Shows how NFC combines decomposed characters (shorter length) + - Shows how NFD decomposes composed characters (longer length) + - Demonstrates equivalence classes: different representations normalize to same result + - Tests both composed (é) and decomposed (e + ´) input forms + """ + print("\n=== Manual Test Cases ===") + + for test_case in self.manual_test_cases: + analysis = self.analyze_unicode_string(test_case) + + print(f"\nTest case: '{test_case}'") + print(f" Original codepoints: {analysis['codepoints']}") + print(f" Character names: {analysis['char_names']}") + + # Show all normalization forms - demonstrates KEY FINDING #2 + for form in ['nfc', 'nfd', 'nfkc', 'nfkd']: + normalized = analysis[form] + norm_codepoints = [hex(ord(c)) for c in normalized] + print(f" {form.upper():4}: '{normalized}' -> {norm_codepoints}") + + # Verify normalization is idempotent (applying twice gives same result) + double_normalized = unicodedata.normalize(form.upper(), normalized) + assert double_normalized == normalized, f"Double {form.upper()} normalization changed result" + + def test_case_sensitivity_with_normalization(self): + """Test how case affects normalization. + + CONFIRMS KEY FINDING #4: + - Case is preserved through all normalization forms + - "café" stays lowercase, "CAFÉ" stays uppercase + - Normalization affects accents but not case + """ + test_cases = [ + ("café", "CAFÉ"), # French word with acute accent + ("niño", "NIÑO"), # Spanish word with tilde + ] + + print("\n=== Case Sensitivity Tests ===") + + for lower, upper in test_cases: + print(f"\nTesting: '{lower}' vs '{upper}'") + + for form in ['NFC', 'NFD', 'NFKC', 'NFKD']: + lower_norm = unicodedata.normalize(form, lower) + upper_norm = unicodedata.normalize(form, upper) + + print(f" {form}: '{lower_norm}' vs '{upper_norm}'") + + # Case should be preserved through normalization + assert lower_norm.lower() == lower_norm + assert upper_norm.upper() == upper_norm + + # Normalization should not change case + assert lower_norm != upper_norm + + def test_normalization_equivalence_classes(self): + """Test that different representations normalize to the same result. + + CONFIRMS KEY FINDING #5: + - Different Unicode representations of same character normalize to same forms + - Composed "é" and decomposed "e + ´" both normalize to same NFC and NFD results + - This is critical for BIP-39 mnemonic validation across different input methods + """ + equivalence_classes = [ + # Different ways to represent é (composed vs decomposed) + ["é", "e\u0301"], # U+00E9 vs U+0065+U+0301 + # Different ways to represent ñ (composed vs decomposed) + ["ñ", "n\u0303"], # U+00F1 vs U+006E+U+0303 + ] + + print("\n=== Equivalence Classes ===") + + for equiv_class in equivalence_classes: + print(f"\nTesting equivalence class: {[repr(s) for s in equiv_class]}") + + # All should normalize to the same NFC form + nfc_results = [unicodedata.normalize('NFC', s) for s in equiv_class] + assert len(set(nfc_results)) == 1, f"NFC normalization not consistent: {nfc_results}" + + # All should normalize to the same NFD form + nfd_results = [unicodedata.normalize('NFD', s) for s in equiv_class] + assert len(set(nfd_results)) == 1, f"NFD normalization not consistent: {nfd_results}" + + print(f" NFC result: {repr(nfc_results[0])}") + print(f" NFD result: {repr(nfd_results[0])}") + + def test_accent_removal_for_fallback_matching(self): + """Test accent removal for fallback matching while preserving non-Latin scripts. + + This test creates a function to remove accents from Latin, Cyrillic, and Greek + characters while leaving Chinese, Japanese, Korean, and other scripts unchanged. + This could be useful for fuzzy matching when exact Unicode matches fail. + """ + + def remove_accents_safe(text: str) -> str: + """Remove accents from Latin/Cyrillic/Greek scripts, preserve other scripts.""" + + # First normalize to NFD to separate base characters from combining diacritics + nfd_text = unicodedata.normalize('NFD', text) + + result = [] + for char in nfd_text: + # Get the Unicode category and script information + category = unicodedata.category(char) + + # Skip combining characters (accents) for Latin, Cyrillic, Greek scripts + if category.startswith('M'): # Mark (combining) characters + # Only remove combining marks that are typically accent marks + # Check if the previous character was from a script we want to modify + if result and self._is_latin_cyrillic_greek_script(result[-1]): + # Skip this combining character (remove the accent) + continue + + # Keep the base character + result.append(char) + + return ''.join(result) + + print("\n=== Accent Removal Tests ===") + + # Test cases for different scripts + test_cases = [ + # Latin script - should have accents removed + { + 'input': 'café', + 'expected': 'cafe', + 'script': 'Latin', + 'should_change': True + }, + { + 'input': 'niño', + 'expected': 'nino', + 'script': 'Latin', + 'should_change': True + }, + { + 'input': 'académie', + 'expected': 'academie', + 'script': 'Latin', + 'should_change': True + }, + { + 'input': 'algèbre', + 'expected': 'algebre', + 'script': 'Latin', + 'should_change': True + }, + # Greek script - should have accents removed (if any) + { + 'input': 'αβγ', # Greek letters without accents + 'expected': 'αβγ', + 'script': 'Greek', + 'should_change': False + }, + # Cyrillic script - should have accents removed (if any) + { + 'input': 'абв', # Cyrillic letters + 'expected': 'абв', + 'script': 'Cyrillic', + 'should_change': False + }, + # Chinese - should be left unchanged + { + 'input': '中文', + 'expected': '中文', + 'script': 'Chinese', + 'should_change': False + }, + # Japanese Hiragana - should be left unchanged + { + 'input': 'あいう', + 'expected': 'あいう', + 'script': 'Japanese Hiragana', + 'should_change': False + }, + # Japanese Katakana - should be left unchanged + { + 'input': 'アイウ', + 'expected': 'アイウ', + 'script': 'Japanese Katakana', + 'should_change': False + }, + # Korean - should be left unchanged + { + 'input': '한글', + 'expected': '한글', + 'script': 'Korean', + 'should_change': False + }, + # Arabic - should be left unchanged + { + 'input': 'العربية', + 'expected': 'العربية', + 'script': 'Arabic', + 'should_change': False + }, + # Hebrew - should be left unchanged + { + 'input': 'עברית', + 'expected': 'עברית', + 'script': 'Hebrew', + 'should_change': False + }, + # Mixed script - only Latin parts should be modified + { + 'input': 'café中文', + 'expected': 'cafe中文', + 'script': 'Mixed Latin+Chinese', + 'should_change': True + } + ] + + for case in test_cases: + input_text = case['input'] + expected = case['expected'] + script = case['script'] + should_change = case['should_change'] + + result = remove_accents_safe(input_text) + + print(f"\nTesting {script}: '{input_text}' -> '{result}'") + print(f" Expected: '{expected}'") + print(f" Should change: {should_change}") + print(f" Did change: {result != input_text}") + + assert result == expected, f"Failed for {script}: '{input_text}' -> '{result}', expected '{expected}'" + + # Verify our expectation about whether it should change + if should_change: + assert result != input_text, f"Expected {script} text to change but it didn't: '{input_text}'" + else: + assert result == input_text, f"Expected {script} text to remain unchanged but it changed: '{input_text}' -> '{result}'" + + def _is_latin_cyrillic_greek_script(self, char: str) -> bool: + """Check if character belongs to Latin, Cyrillic, or Greek script.""" + if not char: + return False + + code_point = ord(char) + + # Latin script ranges (Basic Latin, Latin-1 Supplement, Latin Extended A/B, etc.) + latin_ranges = [ + (0x0041, 0x007A), # Basic Latin A-Z, a-z + (0x00C0, 0x00FF), # Latin-1 Supplement (À-ÿ) + (0x0100, 0x017F), # Latin Extended-A + (0x0180, 0x024F), # Latin Extended-B + (0x1E00, 0x1EFF), # Latin Extended Additional + ] + + # Greek script ranges + greek_ranges = [ + (0x0370, 0x03FF), # Greek and Coptic + (0x1F00, 0x1FFF), # Greek Extended + ] + + # Cyrillic script ranges + cyrillic_ranges = [ + (0x0400, 0x04FF), # Cyrillic + (0x0500, 0x052F), # Cyrillic Supplement + ] + + # Check if character falls in any of these ranges + all_ranges = latin_ranges + greek_ranges + cyrillic_ranges + + for start, end in all_ranges: + if start <= code_point <= end: + return True + + return False + + def test_accent_removal_with_bip39_words(self): + """Test accent removal specifically with BIP-39 words from accented languages.""" + + def remove_accents_safe(text: str) -> str: + """Remove accents from Latin/Cyrillic/Greek scripts, preserve other scripts.""" + nfd_text = unicodedata.normalize('NFD', text) + result = [] + for char in nfd_text: + category = unicodedata.category(char) + if category.startswith('M'): # Mark (combining) characters + if result and self._is_latin_cyrillic_greek_script(result[-1]): + continue # Skip accent marks on Latin/Cyrillic/Greek characters + result.append(char) + return ''.join(result) + + print("\n=== BIP-39 Word Accent Removal Tests ===") + + # Test with actual French BIP-39 words (using our test words from setup_class) + if hasattr(self, 'test_words') and 'french' in self.test_words: + for word in self.test_words['french'][:5]: # Test first 5 French words + deaccented = remove_accents_safe(word) + print(f" French: '{word}' -> '{deaccented}'") + # Verify that accents were actually removed (should be shorter or different) + if any(ord(c) > 127 for c in word): # Contains non-ASCII (accented chars) + assert word != deaccented, f"Expected accent removal for '{word}'" + + # Test with actual Spanish BIP-39 words + if hasattr(self, 'test_words') and 'spanish' in self.test_words: + for word in self.test_words['spanish'][:5]: # Test first 5 Spanish words + deaccented = remove_accents_safe(word) + print(f" Spanish: '{word}' -> '{deaccented}'") + # Verify that accents were actually removed + if any(ord(c) > 127 for c in word): # Contains non-ASCII (accented chars) + assert word != deaccented, f"Expected accent removal for '{word}'" + + # Test with known examples + test_examples = [ + ('académie', 'academie'), + ('acquérir', 'acquerir'), + ('algèbre', 'algebre'), + ('ábaco', 'abaco'), + ('acción', 'accion'), + ('niño', 'nino') + ] + + for original, expected in test_examples: + deaccented = remove_accents_safe(original) + print(f" '{original}' -> '{deaccented}'") + assert deaccented == expected, f"Expected '{expected}', got '{deaccented}'" + + # Test that non-Latin scripts are unchanged + non_latin_examples = [ + '中文', # Chinese + 'あいう', # Japanese Hiragana + 'アイウ', # Japanese Katakana + '한글', # Korean + 'العربية', # Arabic + 'עברית' # Hebrew + ] + + for word in non_latin_examples: + deaccented = remove_accents_safe(word) + print(f" Non-Latin '{word}' -> '{deaccented}' (should be unchanged)") + assert deaccented == word, f"Non-Latin script should not change: '{word}' -> '{deaccented}'" + + def test_character_category_analysis(self): + """Analyze Unicode character categories before and after normalization. + + This test examines the Unicode categories of characters in different scripts + to understand how NFKD/NFD decomposition affects character composition. + Categories include: + - L*: Letters (Lu=uppercase, Ll=lowercase, Lt=titlecase, Lm=modifier, Lo=other) + - M*: Marks/Combining (Mn=nonspacing, Mc=spacing, Me=enclosing) + - N*: Numbers (Nd=decimal, Nl=letter, No=other) + - P*: Punctuation + - S*: Symbols + - Z*: Separators + - C*: Control/Format + """ + + def analyze_character_categories(text: str, label: str) -> UnicodeAnalysis: + """Analyze character categories in a text string.""" + categories = [] + category_counts = {} + scripts = set() + character_details = [] + + for i, char in enumerate(text): + category = unicodedata.category(char) + name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') + + # Try to determine script from character name + script = self._determine_script_from_name(name) + if script: + scripts.add(script) + + char_info = CharacterInfo( + char=char, + ord=ord(char), + hex=f'U+{ord(char):04X}', + category=category, + name=name, + script=script + ) + + categories.append(category) + category_counts[category] = category_counts.get(category, 0) + 1 + character_details.append(char_info) + + return UnicodeAnalysis( + text=text, + label=label, + length=len(text), + categories=categories, + category_counts=category_counts, + scripts=scripts, + character_details=character_details + ) + + def compare_normalizations(text: str, label: str): + """Compare character categories across different normalizations.""" + print(f"\n=== Character Category Analysis: {label} ===") + print(f"Original text: '{text}'") + + # Analyze different normalization forms + forms = { + 'Original': text, + 'NFC': unicodedata.normalize('NFC', text), + 'NFD': unicodedata.normalize('NFD', text), + 'NFKC': unicodedata.normalize('NFKC', text), + 'NFKD': unicodedata.normalize('NFKD', text) + } + + analyses = {} + for form_name, form_text in forms.items(): + analyses[form_name] = analyze_character_categories(form_text, f"{label}-{form_name}") + + # Print summary comparison + print(f"Length changes: {', '.join(f'{name}={analysis.length}' for name, analysis in analyses.items())}") + + # Show category distribution for each form + for form_name, analysis in analyses.items(): + if analysis.category_counts: + categories_str = ', '.join(f"{cat}×{count}" for cat, count in sorted(analysis.category_counts.items())) + print(f"{form_name:8}: {categories_str}") + + # Show detailed character breakdown for NFD and NFKD (most detailed forms) + for form_name in ['NFD', 'NFKD']: + if form_name in analyses: + analysis = analyses[form_name] + if analysis.character_details: + print(f"\n{form_name} detailed breakdown:") + for char_info in analysis.character_details: + print(f" '{char_info.char}' {char_info.hex} {char_info.category} - {char_info.name}") + + return analyses + + print("\n=== Unicode Character Category Analysis ===") + + # Test cases covering different script types + test_cases = [ + # Latin script with accents + { + 'text': 'café', + 'label': 'Latin with acute accent', + 'expected_decomposition': True + }, + { + 'text': 'niño', + 'label': 'Latin with tilde', + 'expected_decomposition': True + }, + { + 'text': 'résumé', + 'label': 'Latin with multiple accents', + 'expected_decomposition': True + }, + # Greek script + { + 'text': 'αβγδε', + 'label': 'Greek basic letters', + 'expected_decomposition': False + }, + { + 'text': 'άλφα', # Greek with accent if available + 'label': 'Greek with accent', + 'expected_decomposition': True + }, + # Cyrillic script + { + 'text': 'привет', + 'label': 'Cyrillic basic letters', + 'expected_decomposition': False + }, + # Chinese script + { + 'text': '中文字符', + 'label': 'Chinese ideographs', + 'expected_decomposition': False + }, + # Japanese Hiragana + { + 'text': 'ひらがな', + 'label': 'Japanese Hiragana', + 'expected_decomposition': False + }, + # Japanese Katakana + { + 'text': 'カタカナ', + 'label': 'Japanese Katakana', + 'expected_decomposition': False + }, + # Korean + { + 'text': '한글문자', + 'label': 'Korean Hangul', + 'expected_decomposition': False + }, + # Arabic script + { + 'text': 'العربية', + 'label': 'Arabic script', + 'expected_decomposition': False + }, + # Hebrew script + { + 'text': 'עברית', + 'label': 'Hebrew script', + 'expected_decomposition': False + }, + # Compatibility characters + { + 'text': 'file', # Contains fi ligature + 'label': 'Latin with ligature', + 'expected_decomposition': True + }, + { + 'text': 'abc', # Fullwidth + 'label': 'Fullwidth Latin', + 'expected_decomposition': True + }, + { + 'text': 'café²', # Superscript + 'label': 'Latin with superscript', + 'expected_decomposition': True + } + ] + + # Run analysis on each test case + all_analyses = {} + for case in test_cases: + text = case['text'] + label = case['label'] + expected_decomp = case['expected_decomposition'] + + analyses = compare_normalizations(text, label) + all_analyses[label] = analyses + + # Verify our expectations about decomposition + original_len = analyses['Original'].length + nfd_len = analyses['NFD'].length + nfkd_len = analyses['NFKD'].length + + if expected_decomp: + # Should see length increase or category changes with NFD/NFKD + has_decomposition = (nfd_len > original_len or + nfkd_len > original_len or + analyses['NFD'].category_counts != analyses['Original'].category_counts or + analyses['NFKD'].category_counts != analyses['Original'].category_counts) + assert has_decomposition, f"Expected decomposition for {label} but none found" + else: + # Should remain mostly unchanged (unless NFKC/NFKD do compatibility transforms) + # Note: Some scripts might still have minor changes due to Unicode normalization + pass # We'll just observe the results for non-decomposing scripts + + # Summary analysis + print(f"\n=== Character Category Summary ===") + + # Collect all unique categories seen + all_categories = set() + for label, analyses in all_analyses.items(): + for form_name, analysis in analyses.items(): + all_categories.update(analysis.category_counts.keys()) + + print(f"All Unicode categories observed: {', '.join(sorted(all_categories))}") + + # Count by script type + script_stats = {} + for label, analyses in all_analyses.items(): + for script in analyses['Original'].scripts: + if script not in script_stats: + script_stats[script] = [] + script_stats[script].append(label) + + print(f"\nScript distribution:") + for script, labels in script_stats.items(): + print(f" {script}: {len(labels)} test cases") + + def _determine_script_from_name(self, char_name: str) -> str: + """Determine script from Unicode character name.""" + name_upper = char_name.upper() + + # Script indicators in Unicode names + script_indicators = { + 'LATIN': 'Latin', + 'GREEK': 'Greek', + 'CYRILLIC': 'Cyrillic', + 'CJK': 'CJK', + 'HIRAGANA': 'Japanese', + 'KATAKANA': 'Japanese', + 'HANGUL': 'Korean', + 'ARABIC': 'Arabic', + 'HEBREW': 'Hebrew', + 'COMBINING': 'Combining', + 'FULLWIDTH': 'Fullwidth', + 'SUPERSCRIPT': 'Superscript', + 'SUBSCRIPT': 'Subscript', + 'LIGATURE': 'Ligature', + 'ROMAN NUMERAL': 'Roman' + } + + for indicator, script in script_indicators.items(): + if indicator in name_upper: + return script + + # Check for specific Unicode block ranges + if not char_name or 'UNKNOWN' in char_name: + return 'Unknown' + + return 'Other' + + def test_bip39_wordlist_character_categories(self): + """Analyze character categories in actual BIP-39 wordlists.""" + + def analyze_wordlist_categories(language: str, max_words: int = 10): + """Analyze character categories in a BIP-39 wordlist.""" + print(f"\n=== BIP-39 Wordlist Analysis: {language.capitalize()} ===") + + # Get words from the wordlist using our test setup + if not hasattr(self, 'test_words') or language not in self.test_words: + print(f"No test words available for {language}") + return + + words = self.test_words[language][:max_words] + + category_summary = {} + script_summary = set() + + for word in words: + print(f"\nWord: '{word}'") + + # Analyze original word + for i, char in enumerate(word): + category = unicodedata.category(char) + name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') + script = self._determine_script_from_name(name) + + print(f" [{i}] '{char}' U+{ord(char):04X} {category} - {name}") + + category_summary[category] = category_summary.get(category, 0) + 1 + script_summary.add(script) + + # Show NFD decomposition + nfd_word = unicodedata.normalize('NFD', word) + if nfd_word != word: + print(f" NFD decomposition: '{nfd_word}' (length {len(nfd_word)})") + for i, char in enumerate(nfd_word): + if i >= len(word) or char != word[i]: + category = unicodedata.category(char) + name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') + print(f" [{i}] '{char}' U+{ord(char):04X} {category} - {name}") + + print(f"\nCategory summary for {language}:") + for category, count in sorted(category_summary.items()): + print(f" {category}: {count}") + + print(f"Scripts found: {', '.join(sorted(script_summary))}") + + return category_summary, script_summary + + # Analyze available wordlists + if hasattr(self, 'test_words'): + for language in self.test_words.keys(): + analyze_wordlist_categories(language, max_words=5) + + # Also test some known challenging cases + print(f"\n=== Manual BIP-39 Word Examples ===") + + challenging_words = [ + ('académie', 'French with acute accent'), + ('algèbre', 'French with grave accent'), + ('ábaco', 'Spanish with acute accent'), + ('niño', 'Spanish with tilde'), + ('acción', 'Spanish with acute accent') + ] + + for word, description in challenging_words: + print(f"\n{description}: '{word}'") + + # Show character-by-character breakdown + for i, char in enumerate(word): + category = unicodedata.category(char) + name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') + print(f" [{i}] '{char}' U+{ord(char):04X} {category} - {name}") + + # Show NFD breakdown + nfd_word = unicodedata.normalize('NFD', word) + if nfd_word != word: + print(f" NFD: '{nfd_word}'") + for i, char in enumerate(nfd_word): + category = unicodedata.category(char) + name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') + combining = " (COMBINING)" if category.startswith('M') else "" + print(f" [{i}] '{char}' U+{ord(char):04X} {category}{combining} - {name}") + + def test_normalization_preserves_meaning(self): + """Test that normalization preserves the semantic meaning of words. + + CONFIRMS that normalization is reversible and consistent: + - Converting NFD→NFC→NFD produces the original NFD form + - Converting NFC→NFD→NFC produces the original NFC form + - Double normalization is idempotent (same result) + - Critical for ensuring BIP-39 mnemonics work regardless of input normalization + """ + for language, words in self.test_words.items(): + for word in words: + nfc_word = unicodedata.normalize('NFC', word) + nfd_word = unicodedata.normalize('NFD', word) + + # While byte representation may differ, they should represent the same word + # This is more of a documentation test - we can't programmatically verify + # semantic equivalence, but we can verify they normalize consistently + + # Double normalization should be idempotent + assert unicodedata.normalize('NFC', nfc_word) == nfc_word + assert unicodedata.normalize('NFD', nfd_word) == nfd_word + + # Converting between forms should be consistent (reversible) + assert unicodedata.normalize('NFC', nfd_word) == nfc_word + assert unicodedata.normalize('NFD', nfc_word) == nfd_word + + def test_compatibility_character_normalization(self): + """Test NFKC/NFKD normalization effects on compatibility characters. + + CRITICAL ANALYSIS FOR BIP-39 PROCESSING: + This test determines whether BIP-39 implementations need to handle + compatibility normalization when comparing user input to wordlist words. + + Compatibility characters that might appear in user input: + - Fullwidth characters from Asian input methods (abc → abc) + - Roman numerals (Ⅰ → I) + - Ligatures (fi → fi) + - Circled numbers (① → 1) + - Super/subscripts (² → 2) + + Key questions answered: + 1. Do NFKC/NFKD change compatibility chars differently than NFC/NFD? + 2. Could user input contain compatibility chars that map to BIP-39 words? + 3. Should BIP-39 validation use NFKC instead of NFC for robustness? + """ + print("\n=== Compatibility Character Analysis ===") + + compatibility_transformations = [] + + for test_case in self.compatibility_test_cases: + analysis = self.analyze_unicode_string(test_case) + + print(f"\nCompatibility test: '{test_case}'") + print(f" Original codepoints: {analysis['codepoints']}") + print(f" Character names: {analysis['char_names']}") + + # Compare all normalization forms + nfc_result = analysis['nfc'] + nfd_result = analysis['nfd'] + nfkc_result = analysis['nfkc'] + nfkd_result = analysis['nfkd'] + + print(f" NFC : '{nfc_result}' -> {[hex(ord(c)) for c in nfc_result]}") + print(f" NFD : '{nfd_result}' -> {[hex(ord(c)) for c in nfd_result]}") + print(f" NFKC: '{nfkc_result}' -> {[hex(ord(c)) for c in nfkc_result]}") + print(f" NFKD: '{nfkd_result}' -> {[hex(ord(c)) for c in nfkd_result]}") + + # Check if NFKC/NFKD produce different results than NFC/NFD + canonical_vs_compatibility = (nfc_result != nfkc_result) or (nfd_result != nfkd_result) + + if canonical_vs_compatibility: + transformation = { + 'original': test_case, + 'nfc': nfc_result, + 'nfkc': nfkc_result, + 'transformation_type': 'compatibility_normalization' + } + compatibility_transformations.append(transformation) + print(f" *** COMPATIBILITY TRANSFORMATION: '{test_case}' -> '{nfkc_result}' ***") + else: + print(f" No compatibility transformation (NFC == NFKC)") + + # Store results for analysis + self.compatibility_transformations = compatibility_transformations + + # Verify we found some compatibility transformations + assert len(compatibility_transformations) > 0, "Expected to find compatibility character transformations" + + print(f"\nFound {len(compatibility_transformations)} compatibility transformations") + + def test_bip39_wordlist_compatibility_analysis(self): + """Analyze whether BIP-39 wordlists contain any compatibility characters. + + DETERMINES: Do actual BIP-39 wordlists use compatibility characters? + If not, then compatibility normalization is only needed for user input processing. + """ + print("\n=== BIP-39 Wordlist Compatibility Analysis ===") + + compatibility_found_in_wordlists = [] + + for language, words in self.test_words.items(): + print(f"\nAnalyzing {language} wordlist...") + + for word in words: + nfc_form = unicodedata.normalize('NFC', word) + nfkc_form = unicodedata.normalize('NFKC', word) + + if nfc_form != nfkc_form: + compatibility_found_in_wordlists.append({ + 'language': language, + 'word': word, + 'nfc': nfc_form, + 'nfkc': nfkc_form + }) + print(f" COMPATIBILITY CHARACTER FOUND: '{word}' -> '{nfkc_form}'") + + if compatibility_found_in_wordlists: + print(f"\nWARNING: Found {len(compatibility_found_in_wordlists)} compatibility characters in wordlists!") + for item in compatibility_found_in_wordlists: + print(f" {item['language']}: '{item['word']}' -> '{item['nfkc']}'") + else: + print("\nRESULT: No compatibility characters found in BIP-39 wordlists") + print("This confirms BIP-39 wordlists use only canonical Unicode forms") + + def test_user_input_compatibility_scenarios(self): + """Test realistic user input scenarios with compatibility characters. + + PRACTICAL ANALYSIS: What happens when users enter compatibility characters + that could map to valid BIP-39 words after NFKC normalization? + """ + print("\n=== User Input Compatibility Scenarios ===") + + # Simulate user input scenarios that might contain compatibility chars + user_input_scenarios = [ + # Fullwidth input (common with Asian keyboards) + { + 'input': 'académie', # Fullwidth 'académie' + 'expected_word': 'académie', + 'scenario': 'Asian keyboard fullwidth input' + }, + # Mixed fullwidth/normal + { + 'input': 'action', # Fullwidth 'a' + normal 'ction' + 'expected_word': 'action', + 'scenario': 'Mixed fullwidth/normal input' + }, + # Ligature input + { + 'input': 'profit', # Contains ligature fi + 'expected_word': 'profit', + 'scenario': 'Ligature character input' + }, + ] + + bip39_validation_recommendations = [] + + for scenario in user_input_scenarios: + user_input = scenario['input'] + expected = scenario['expected_word'] + description = scenario['scenario'] + + print(f"\nScenario: {description}") + print(f" User input: '{user_input}'") + print(f" Expected word: '{expected}'") + + # Test different normalization approaches + nfc_result = unicodedata.normalize('NFC', user_input) + nfkc_result = unicodedata.normalize('NFKC', user_input) + + print(f" NFC normalization: '{nfc_result}'") + print(f" NFKC normalization: '{nfkc_result}'") + + # Check if NFKC helps match the expected word + nfc_matches_expected = (nfc_result == expected) + nfkc_matches_expected = (nfkc_result == expected) + + print(f" NFC matches expected: {nfc_matches_expected}") + print(f" NFKC matches expected: {nfkc_matches_expected}") + + if not nfc_matches_expected and nfkc_matches_expected: + recommendation = f"NFKC normalization needed for: {description}" + bip39_validation_recommendations.append(recommendation) + print(f" *** RECOMMENDATION: Use NFKC normalization for this scenario ***") + elif nfc_matches_expected: + print(f" NFC normalization sufficient") + + # Store recommendations for final analysis + self.bip39_validation_recommendations = bip39_validation_recommendations + + if bip39_validation_recommendations: + print(f"\n=== BIP-39 VALIDATION RECOMMENDATIONS ===") + for rec in bip39_validation_recommendations: + print(f" • {rec}") + else: + print(f"\nNo special compatibility handling needed for tested scenarios") + + def test_compatibility_normalization_security_implications(self): + """Analyze security implications of compatibility normalization in BIP-39. + + SECURITY ANALYSIS: Could compatibility normalization create security issues? + - Homograph attacks (different characters that look similar) + - Unexpected transformations that change meaning + - Compatibility chars that map to multiple possible words + """ + print("\n=== Compatibility Normalization Security Analysis ===") + + # Test potential homograph/confusable scenarios + potentially_confusing_cases = [ + # Roman numeral vs Latin letters + { + 'char1': 'I', # Latin capital I + 'char2': 'Ⅰ', # Roman numeral I + 'concern': 'Roman numeral I vs Latin I' + }, + # Fullwidth vs normal + { + 'char1': 'a', # Normal Latin a + 'char2': 'a', # Fullwidth Latin a + 'concern': 'Fullwidth vs normal Latin letters' + }, + # Ligature vs separate chars + { + 'char1': 'fi', # Separate f + i + 'char2': 'fi', # Ligature fi + 'concern': 'Ligature fi vs separate f+i' + } + ] + + security_warnings = [] + + for case in potentially_confusing_cases: + char1 = case['char1'] + char2 = case['char2'] + concern = case['concern'] + + # Test if they normalize to the same thing under NFKC + nfkc1 = unicodedata.normalize('NFKC', char1) + nfkc2 = unicodedata.normalize('NFKC', char2) + + print(f"\nTesting: {concern}") + print(f" '{char1}' NFKC-> '{nfkc1}'") + print(f" '{char2}' NFKC-> '{nfkc2}'") + + if nfkc1 == nfkc2: + warning = f"SECURITY CONCERN: {concern} normalize to same result under NFKC" + security_warnings.append(warning) + print(f" *** {warning} ***") + else: + print(f" No normalization collision") + + # Final security assessment + print(f"\n=== SECURITY ASSESSMENT ===") + if security_warnings: + print(f"Found {len(security_warnings)} potential security concerns:") + for warning in security_warnings: + print(f" ⚠️ {warning}") + print(f"\nRECOMMENDATION: Carefully validate NFKC normalization in BIP-39 implementations") + else: + print(f"No obvious security concerns found with NFKC normalization") + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) From 28bbc26dd5d14876a12236418420fa8a55210aaf Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Fri, 12 Sep 2025 14:02:30 -0600 Subject: [PATCH 18/38] Support abbreviations and optional UTF-8 "Marks" for mnemonic languages --- .gitignore | 2 + hdwallet/mnemonics/imnemonic.py | 324 ++++++++++++++++++++------- tests/test_bip39_cross_language.py | 345 ++++++++++++++++++++++------- 3 files changed, 522 insertions(+), 149 deletions(-) diff --git a/.gitignore b/.gitignore index 59d156be..4d23741c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ __pycache__/ # NixOS stuff flake.lock +CLAUDE.md +.claude diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 3af05d81..46551150 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -7,8 +7,11 @@ from abc import ( ABC, abstractmethod ) +from collections.abc import ( + Mapping +) from typing import ( - Union, Dict, Set, List, Tuple, Optional + Any, Callable, Union, Dict, Generator, List, Set, Tuple, Optional ) import os @@ -21,6 +24,243 @@ from ..entropies import IEntropy +class TrieError(Exception): + pass + + +class Ambiguous(TrieError): + def __init__(self, message, word: str, options: Set[str]): + super().__init__( message ) + self.word = word + self.options = options + + +class TrieNode: + # Associates a value with a node in a trie. + # + # The EMPTY marker indicates that a word ending in this TrieNode was not inserted into the True; + # replace with something that will never be provided as a word's 'value', preferably something + # "Falsey". An insert defaults to PRESENT, preferably something "Truthy". + EMPTY = None + PRESENT = True + def __init__(self): + self.children = defaultdict(self.__class__) + self.value = self.__class__.EMPTY + + +class Trie: + + def __init__(self, root=None): + """ + Initialize your data structure here. + """ + self.root = root if root is not None else TrieNode() + + def insert(self, word: str, value: Optional[Any] = None) -> None: + """ + Inserts a 'word' into the Trie, associated with 'value'. + """ + current = self.root + for letter in word: + current = current.children[letter] + assert current.value is current.EMPTY, \ + f"Attempt to re-insert {word!r}; already present with value {current.value!r}" + current.value = current.PRESENT if value is None else value + + def find(self, word: str, current: Optional[TrieNode] = None) -> Generator[Tuple[str, Optional[TrieNode]], None, None]: + """Finds all the TrieNode that match the word, optionally from the provided 'current' node. + + If the word isn't in the current Trie, terminates by producing None for the TrieNode. + + """ + if current is None: + current = self.root + yield current.value is not current.EMPTY, '', current + + for letter in word: + current = current.children.get(letter) + if current is None: + yield False, '', None + break + yield current.value is not current.EMPTY, letter, current + + def complete(self, current: TrieNode) -> Generator[Tuple[str, TrieNode], None, None]: + """Generate (, key, node) tuples along an unambiguous path starting from after + the current TrieNode, until the next terminal TrieNode is encountered. + + Continues until a non-EMPTY value is found, or the path becomes ambiguous. Tests for a + terminal value *after* transitioning, so we can use .complete to move from unique terminal + node to unique terminal node, eg. 'ad' --> 'add' --> 'addict' + + Will only yield candidates that are on an unambiguous path; the final candidate's terminal + flag must be evaluated to determine if it indicates a completed word was found. + + """ + terminal = False + while current is not None and not terminal and len( current.children ) == 1: + # Follow unique path until we hit ambiguity or a terminal (non-empty) node + (key, current), = current.children.items() + terminal = current.value is not current.EMPTY + yield terminal, key, current + + def search(self, word: str, current: Optional[TrieNode] = None, complete: bool = False) -> Tuple[str, Optional[TrieNode]]: + """Returns the matched stem, and associated TrieNode if the word is in the trie (otherwise None) + + If 'complete' and 'word' is an unambiguous abbreviation of some word with a non-EMPTY value, + return the node. + + The word could be complete and have a non-EMPTY TrieNode.value, but also could be a prefix + of other words, so the caller may need to consult the return TrieNode.children. + + """ + stem = '' + for terminal, c, current in self.find( word, current=current ): + stem += c + if complete and current is not None and current.value is current.EMPTY: + for terminal, c, current in self.complete( current=current ): + stem += c + return terminal, stem, current + + def __contains__(self, word: str) -> bool: + """True iff 'word' has been associated with (or is a unique prefix of) a value in the trie.""" + _, _, result = self.search(word, complete=True) + return result is not None + + def startswith(self, prefix: str) -> bool: + """ + Returns if there is any word(s) in the trie that start with the given prefix. + """ + _, _, result = self.search(prefix) + return result is not None + + def scan( + self, + prefix: str = '', + current: Optional[TrieNode] = None, + depth: int = 0, + predicate: Optional[Callable[[TrieNode], bool]] = None, # default: terminal + ) -> Generator[Tuple[str, TrieNode], None, None]: + """Yields all strings and their TrieNode that match 'prefix' and satisfy 'predicate' (or are + terminal), in depth-first order. + + Optionally start from the provided 'current' node. + + Any strings that are only prefixes for other string(s) will have node.value == node.EMPTY + (be non-terminal). + + """ + *_, (terminal, _, current) = self.find(prefix, current=current) + if current is None: + return + + satisfied = terminal if predicate is None else predicate( current ) + if satisfied: + yield prefix, current + + if not depth or depth > 1: + for char, child in current.children.items(): + for suffix, found in self.scan( current=child, depth=max(0, depth-1), predicate=predicate ): + yield prefix + char + suffix, found + + +def unmark( word_composed: str ) -> str: + """This word may contain composite characters with accents like "é" that decompose "e" + "'". + Most mnemonic encodings require that mnemonic words without accents match the accented word. + Remove the non-character symbols. + + """ + return ''.join( + c + for c in unicodedata.normalize( "NFD", word_composed ) + if not unicodedata.category( c ).startswith('M') + ) + + +class WordIndices( Mapping ): + """Holds a Sequence of Mnemonic words, and is indexable either by int (returning the original + word), or by the original word (with or without Unicode "Marks") or a unique abbreviations, + returning the int index. + + For non-unique prefixes, indexing returns a Set[str] of next character options. + + """ + def __init__(self, sequence): + """Insert a sequence of Unicode words (and optionally value(s)) into a Trie, making the + "unmarked" version an alias of the regular Unicode version. + + """ + self._trie = Trie() + self._words = [] + for i, word in enumerate( sequence ): + self._words.append( word ) + self._trie.insert( word, i ) + + word_unmarked = unmark( word ) + if word == word_unmarked or len( word ) != len( word_unmarked ): + # If the word has no marks, or if the unmarked word doesn't have the same number of + # glyphs, we can't "alias" it. + #print( f"Not inserting {word!r}; unmarked {word_unmarked!r} identical or incompatible" ) + continue + # Traverse the TrieNodes representing 'word'. Each character in word and word_unmarked + # is joined by the TrieNode which contains it in .children, and we should never get a + # None (lose the plot) because we've just inserted 'word'! This will just be idempotent + # unless c_u != c. + #print( f"Inserting {word:10} alias: {word_unmarked}" ) + for c, c_u, (_, _, n) in zip( word, word_unmarked, self._trie.find( word )): + assert c in n.children + n.children[c_u] = n.children[c] + + def __getitem__(self, key: Tuple[int, str]): + """A Mapping to find a word by spelling or index, returning a value Tuple consisting of: + - The canonical word 'str', and + - The index value, and + - the set of options available from the end of word, if any + + If no such 'int' index exists, raises IndexError. If no word(s) are possible starting from + the given 'str', raises KeyError. + + """ + if isinstance( key, int ): + # The key'th word (or IndexError) + return self._words[key], key, set() + + *_, (_, node) = self._trie.find( key, complete=Trie ) + if node is None: + # We're nowhere in the Trie with this word + raise KeyError(f"{key} does not match any word") + + return self._words[node.value], node.value, set(node.children) + + def __len__(self): + return self._words + + def __iter__(self): + return iter( self._words ) + + def keys(self): + return self._words + + def values(self): + return map( self.__getitem__, self._words ) + + def items(self): + return zip( self._words, self.values() ) + + def abbreviations(self): + """All unique abbreviations of words in the Trie. Scans the Trie, identifying each prefix + that uniquely abbreviates a word.""" + def unique( current ): + terminal = False + for terminal, _, complete in self._trie.complete( current ): + pass + return terminal + + for abbrev, node in self._trie.scan( predicate=unique ): + if node.value is node.EMPTY: + # Only abbreviations (not terminal words) that led to a unique terminal word + yield abbrev + + class IMnemonic(ABC): # The specified Mnemonic's details; including the deduced language and all of its word indices @@ -149,85 +389,21 @@ def get_words_list_by_language( return words_list @classmethod - def all_wordslist_indices( + def all_words_indices( cls, wordlist_path: Optional[Dict[str, str]] = None - ) -> Tuple[str, List[str], Dict[str, int]]: - """Yields each 'candidate' language, its NFKC-normalized 'words_list', and its - 'word_indices' dict including optional accents and all unique abbreviations. - - """ - def abbreviated_indices( word_indices ): - """We will support all unambiguous abbreviations; even down to less than 4 characters - (the typically guaranteed /minimum/ unambiguous word size in most Mnemonic encodings.) - This is because Mnemonic inputs often support the absolute minimum input required to - uniquely identify a mnemonic word in a specified language. - - """ - def min_disambiguating_length(word1: str, word2: str): - """Find the minimum length needed to disambiguate word1 from word2. Since the words - cannot be the same, they must differ at a valid index, or one must be longer, so the - resultant index is always a valid index into at least the longer of the two words. - - """ - assert word1 != word2, \ - f"Cannot disambiguate empty or identical words" - for j in range(min(len(word1), len(word2))): - if word1[j] != word2[j]: - return j + 1 - # One is a prefix of the other; first non-prefix character disambiguates - return j + 1 - - words_sorted = sorted( word_indices ) - - pair_disambiguation = list( - min_disambiguating_length( w1, w2 ) - for w1, w2 in zip( words_sorted[0:], words_sorted[1:] ) - ) - for i in range( len( words_sorted ) - 2 ): - beg = min( - pair_disambiguation[i-1 if i > 0 else i], - pair_disambiguation[i], - pair_disambiguation[i+1] - ) - end = min( - len( words_sorted[i-1 if i > 0 else i] ), - len( words_sorted[i] ), - len( words_sorted[i+1] ) - ) - for length in range( beg, end - 1): - abbrev = words_sorted[i][:length] - assert abbrev not in words_sorted, \ - f"Found {abbrev} in {words_sorted!r}" - yield abbrev, word_indices[words_sorted[i]] + ) -> Tuple[str, List[str], WordIndices]: + """Yields each 'candidate' language, its NFKC-normalized words List, and its WordIndices + Mapping supporting indexing by 'int' word index, or 'str' with optional accents and all + unique abbreviations. + """ for candidate in wordlist_path.keys() if wordlist_path else cls.languages: # Normalized NFC, so characters and accents are combined words_list: List[str] = cls.get_words_list_by_language( language=candidate, wordlist_path=wordlist_path ) - word_indices: Dict[str,int] = { - words_list[i]: i for i in range(len( words_list )) - } - - def unmark( word_composed ): - """This word may contain composite characters with accents like "é" that decompose "e - + '". Most mnemonic encodings require that mnemonic words without accents match - the accented word. Remove the non-character symbols.""" - return ''.join( - c - for c in unicodedata.normalize( "NFD", word_composed ) - if not unicodedata.category( c ).startswith('M') - ) - - word_indices_unmarked = { - unmark( word_composed ): i - for word_composed, i in word_indices.items() - } - word_indices.update( word_indices_unmarked ) - - word_indices_abbreviated = dict( abbreviated_indices( word_indices )) - word_indices.update( word_indices_abbreviated ) - + print( f"Language {candidate!r} has {len(words_list)} words" ) + word_indices = WordIndices( words_list ) yield candidate, words_list, word_indices @classmethod @@ -288,7 +464,7 @@ def find_language( """ - language_words_indices: Dict[str, Dict[str, int]] + language_words_indices: Dict[str, Dict[str, int]] = {} quality: Dict[str, int] = {} # How many language symbols were matched for candidate, words_list, words_indices in cls.all_wordslist_indices( wordlist_path=wordlist_path ): language_words_indices[candidate] = words_indices diff --git a/tests/test_bip39_cross_language.py b/tests/test_bip39_cross_language.py index 7c3addbb..1c09db3e 100644 --- a/tests/test_bip39_cross_language.py +++ b/tests/test_bip39_cross_language.py @@ -8,19 +8,20 @@ import pytest from hdwallet.mnemonics.bip39 import BIP39Mnemonic +from hdwallet.mnemonics.imnemonic import Trie, TrieNode class TestBIP39CrossLanguage: """Test BIP39 mnemonics that work in both English and French languages. - + This test explores the theoretical possibility of mnemonics that can be validly - decoded in multiple languages. About 2% (100/2048) of words are common between + decoded in multiple languages. About 2% (100/2048) of words are common between the English and French BIP-39 wordlists. - + For randomly generated entropy uses only common words: - Probability for a 12-word mnemonic: (100/2048)^12*1/16 ≈ 1.15*10^-17 - Probability for a 24-word mnemonic: (100/2048)^24*1/256 ≈ 1.32x10^-34 - + Most wallets allow abbreviations; only the first few characters of the word need to be entered: the words are guaranteed to be unique after entering at least 4 letters (including the word end symbol; eg. 'run' 'runway, and 'sea' 'search' 'season' 'seat'). @@ -32,12 +33,12 @@ class TestBIP39CrossLanguage: These probabilities are astronomically small, so naturally occurring mnemonics will essentially never be composed entirely of common words. - + This test deliberately constructs mnemonics using only common words, then tests what fraction pass checksum validation in both languages: - For 12-word mnemonics: ~1/16 (6.25%) due to 4-bit checksum - For 24-word mnemonics: ~1/256 (0.39%) due to 8-bit checksum - + This demonstrates the theoretical cross-language compatibility while showing why it's not a practical security concern for real-world usage. @@ -50,77 +51,47 @@ class TestBIP39CrossLanguage: """ - @classmethod - def _load_language_wordlist(cls, language: str) -> tuple[set, list]: - """Load wordlist for a given language and compute abbreviations. - - Args: - language: Language name (e.g., 'english', 'french'), NFKC normalized to combine characters and accents - - Returns: - Tuple of (words_set, abbreviations_list) - """ - wordlist_path = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - f"hdwallet/mnemonics/bip39/wordlist/{language}.txt" - ) - with open(wordlist_path, "r", encoding="utf-8") as f: - words = set(unicodedata.normalize("NFKC", word.strip()) for word in f.readlines() if word.strip()) - - # All words are unique in 3 or 4 letters - abbrevs = [ - w[:3] if (w[:3] in words or len(set(aw.startswith(w[:3]) for aw in words)) == 1) else w[:4] - for w in words - ] - - # Debug print for abbreviation conflicts - conflicts = { - ab: set(w for w in words if w.startswith(ab)) - for ab in abbrevs - if ab not in words and len(set(w for w in words if w.startswith(ab))) > 1 - } - if conflicts: - print(f"{language.capitalize()} abbreviation conflicts: {conflicts}") - - assert all(ab in words or len(set(w for w in words if w.startswith(ab))) == 1 for ab in abbrevs) - - return words, abbrevs - @classmethod def setup_class(cls, languages: list[str] = None): """Load wordlists and find common words between specified languages. - + Args: languages: List of language names to load (defaults to ['english', 'french']) """ if languages is None: languages = ['english', 'french'] - + if len(languages) < 2: raise ValueError("At least 2 languages are required for cross-language testing") - + # Load all specified languages language_data = {} - for language, words, words_indices in BIP39Mnemonic.all_wordslist_indices(): - language_data[language] = {'words': words, 'abbrevs': words_indices} + for language, words, words_indices in BIP39Mnemonic.all_words_indices(): + language_data[language] = dict( + words = words, + indices = words_indices, + abbrevs = set( words_indices.abbreviations() ), + ) if language not in languages: continue - + # Set class attributes for backward compatibility - setattr(cls, f"{lang}_words", language_data[language]['words']) - setattr(cls, f"{lang}_abbrevs", language_data[language]['abbrevs']) - - # Find common words across all languages - all_word_sets = [data['words'] for data in language_data.values()] - all_abbrev_lists = [data['abbrevs'] for data in language_data.values()] - - cls.common_words = list(set.intersection(*all_word_sets)) - cls.common_abbrevs = list(set.intersection(*[set(abbrevs) for abbrevs in all_abbrev_lists])) - + setattr(cls, f"{language}_words", language_data[language]['words']) + setattr(cls, f"{language}_indices", language_data[language]['indices']) + setattr(cls, f"{language}_abbrevs", language_data[language]['abbrevs']) + + # Find common words across all languages - only process requested languages + requested_data = {lang: language_data[lang] for lang in languages if lang in language_data} + all_word_sets = [set(data['words']) for data in requested_data.values()] + all_abbrev_lists = [data['abbrevs'] for data in requested_data.values()] + + cls.common_words = list(set.intersection(*all_word_sets)) if all_word_sets else [] + cls.common_abbrevs = list(set.intersection(*all_abbrev_lists)) if all_abbrev_lists else [] + # Print statistics - for lang, data in language_data.items(): + for lang, data in requested_data.items(): print(f"{lang.capitalize()} words: {len(data['words'])}") - + print(f"Common words found: {len(cls.common_words)}") print(f"First 20 common words: {cls.common_words[:20]}") print(f"Common abbrevs found: {len(cls.common_abbrevs)}") @@ -130,10 +101,9 @@ def create_random_mnemonic_from_common_words(self, word_count: int) -> str: """Create a random mnemonic using only common words.""" if len(self.common_words) < word_count: raise ValueError(f"Not enough common words ({len(self.common_words)}) to create {word_count}-word mnemonic") - + selected_words = random.choices(self.common_words, k=word_count) return selected_words - return " ".join(selected_words) def test_common_words_exist(self): """Test that there are common words between English and French wordlists.""" @@ -142,30 +112,30 @@ def test_common_words_exist(self): def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_attempts=1000): """Test N-word mnemonics that work in both English and French.""" successful_both_languages: List[List[str]] = [] - + for _ in range(total_attempts): try: # Generate a random N-word mnemonic from common words mnemonic = self.create_random_mnemonic_from_common_words(words) - + # Try to decode as both English and French - both must succeed (pass checksum) # Note: We expect different entropy values since words have different indices - entropy_english = BIP39Mnemonic.decode(mnemonic, words_list=self.english_words) - entropy_french = BIP39Mnemonic.decode(mnemonic, words_list=self.french_words) - + entropy_english = BIP39Mnemonic.decode(' '.join(mnemonic), language='english') + entropy_french = BIP39Mnemonic.decode(' '.join(mnemonic), language='french') + # If both decode successfully, the mnemonic is valid in both languages successful_both_languages.append(mnemonic) print(f"{words}-word common mnemonics {' '.join(mnemonic)!r}") - + except Exception as exc: # Skip invalid mnemonics (e.g., checksum failures) continue - + success_rate = len(successful_both_languages) / total_attempts - + print(f"{words}-word mnemonics: {len(successful_both_languages)}/{total_attempts} successful ({success_rate:.4f})") print(f"Expected rate: ~{expected_rate:.4f}") - + # Assert we found at least some successful mnemonics assert success_rate > 0, f"No {words}-word mnemonics worked in both languages" @@ -180,8 +150,6 @@ def test_cross_language_12_word_mnemonics(self): """Test 12-word mnemonics that work in both English and French.""" candidates = self.dual_language_N_word_mnemonics(words=12, expected_rate=1/16, total_attempts=1000) - - def test_cross_language_24_word_mnemonics(self): """Test 24-word mnemonics that work in both English and French.""" candidates = self.dual_language_N_word_mnemonics(words=24, expected_rate=1/256, total_attempts=5000) @@ -191,15 +159,242 @@ def test_wordlist_properties(self): # Verify wordlist sizes assert len(self.english_words) == 2048, f"English wordlist should have 2048 words, got {len(self.english_words)}" assert len(self.french_words) == 2048, f"French wordlist should have 2048 words, got {len(self.french_words)}" - + # Verify no duplicates within each wordlist assert len(set(self.english_words)) == len(self.english_words), "English wordlist contains duplicates" assert len(set(self.french_words)) == len(self.french_words), "French wordlist contains duplicates" - + # Verify common words list properties assert len(self.common_words) > 0, "No common words found" assert len(set(self.common_words)) == len(self.common_words), "Common words list contains duplicates" + def test_trie_functionality(self): + """Test the new Trie and TrieNode classes for abbreviation handling.""" + + # Test 1: Default TrieNode markers + trie = Trie() + test_words = [ + "abandon", + "ability", + "able", + "about", + "above", + "absent", + "absorb", + "abstract", + "absurd", + "abuse", + "add", + "addict", + "address", + "adjust", + "access", + "accident", + "account", + "accuse", + "achieve", + ] + + # Insert all test words with their indices + for index, word in enumerate( test_words ): + trie.insert(word, index) + + # Test exact word lookups + terminal, stem, current = trie.search("abandon") + assert terminal and stem == "abandon" and current.value == 0, \ + "Should find exact word 'abandon'" + terminal, stem, current = trie.search("ability") + assert terminal and stem == "ability" and current.value == 1, \ + "Should find exact word 'ability'" + terminal, stem, current = trie.search("nonexistent") + assert not terminal and stem == "" and current is None, \ + "Should not find non-existent word" + + # Test __contains__ method + assert "abandon" in trie, "Trie should contain 'abandon'" + assert "ability" in trie, "Trie should contain 'ability'" + assert "nonexistent" not in trie, "Trie should not contain 'nonexistent'" + + # Test prefix detection with startswith + assert trie.startswith("aba"), "Should find prefix 'aba'" + assert trie.startswith("abil"), "Should find prefix 'abil'" + assert trie.startswith("xyz") == False, "Should not find non-existent prefix 'xyz'" + + # Test unambiguous abbreviation completion + # 'aba' should complete to 'abandon' since it's the only word starting with 'aba' + terminal, stem, current = trie.search("aba", complete=True) + assert terminal and stem == "abandon" and current.value == 0, "Should complete 'aba' to 'abandon' (index 0)" + + # 'abi' should complete to 'ability' since it's the only word starting with 'abi' + terminal, stem, current = trie.search("abi", complete=True) + assert terminal and stem == "ability" and current.value == 1, "Should complete 'abi' to 'ability' (index 1)" + + # 'ab' is ambiguous (abandon, ability, able, about, above, absent, absorb, abstract, absurd, abuse) + terminal, stem, current = trie.search("ab", complete=True) + assert not terminal and stem == "ab" and current.value is current.EMPTY, "Should not complete ambiguous prefix 'ab'" + + # 'acc' is also ambiguous (access, accident, account, accuse) + terminal, stem, current = trie.search("acc", complete=True) + assert not terminal and stem == "acc" and current.value is current.EMPTY, "Should not complete ambiguous prefix 'acc'" + + # 'accid' should complete to 'accident' since it's unambiguous + terminal, stem, current = trie.search("accid", complete=True) + assert terminal and stem == "accident" and current.value == 15, "Should complete 'accid' to 'accident' (index 15)" + + # Test edge cases + terminal, stem, current = trie.search("") + assert not terminal and stem == "" and current.value is TrieNode.EMPTY, "Empty string should return EMPTY; it's a prefix, but no value" + terminal, stem, current = trie.search("", complete=True) + assert not terminal and stem == "a" and current.value is TrieNode.EMPTY, "Empty string with complete should complete to 'a' (all words start with a) and return EMPTY" + + # Test very short abbreviations that should be unambiguous + # 'abl' should complete to 'able' since it's the only match + terminal, stem, current = trie.search("abl", complete=True) + assert terminal and stem == "able" and current.value == 2, "Should complete 'abl' to 'able' (index 2)" + + # Test abbreviations that are longer than needed but still valid; particularly that + # complete=True doesn't jump over a fully complete word. + terminal, stem, current = trie.search("abandon", complete=True) + assert terminal and stem == "abandon" and current.value == 0, "Full word should still work with complete=True" + + print("✓ All default Trie functionality tests passed!") + print(f"✓ Tested with {len(test_words)} words") + print("✓ Verified exact lookups, prefix detection, and unambiguous abbreviation completion") + + def scan_value( w_n ): + return w_n[0], w_n[1].value + + # Test scans of various depths + assert sorted( map( scan_value, trie.scan("abs"))) == [ + ( 'absent', 5, ), + ( 'absorb', 6, ), + ( 'abstract', 7 ), + ( 'absurd', 8 ), + ] + + # Now we see words that are prefixes of other words + + assert sorted( map( scan_value, trie.scan("ad", depth=1 ))) == [ + ] + assert sorted( map( scan_value, trie.scan("ad", depth=1, predicate=lambda _: True ))) == [ + ( 'ad', None ), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=2 ))) == [ + ( 'add', 10), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=2, predicate=lambda _: True ))) == [ + ( 'ad', None ), + ( 'add', 10), + ( 'adj', None ), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=3 ))) == [ + ( 'add', 10), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=3, predicate=lambda _: True ))) == [ + ( 'ad', None ), + ( 'add', 10), + ( 'addi', None ), + ( 'addr', None ), + ( 'adj', None ), + ( 'adju', None ), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=4 ))) == [ + ( 'add', 10), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=4, predicate=lambda _: True ))) == [ + ( 'ad', None ), + ( 'add', 10), + ( 'addi', None ), + ( 'addic', None ), + ( 'addr', None ), + ( 'addre', None ), + ( 'adj', None ), + ( 'adju', None ), + ( 'adjus', None ), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=5 ))) == [ + ( 'add', 10), + ( 'addict', 11), + ( 'adjust', 13), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=5, predicate=lambda _: True ))) == [ + ( 'ad', None ), + ( 'add', 10), + ( 'addi', None ), + ( 'addic', None ), + ( 'addict', 11), + ( 'addr', None ), + ( 'addre', None ), + ( 'addres', None ), + ( 'adj', None ), + ( 'adju', None ), + ( 'adjus', None ), + ( 'adjust', 13 ), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=6 ))) == [ + ( 'add', 10), + ( 'addict', 11), + ( 'address', 12), + ( 'adjust', 13), + ] + assert sorted( map( scan_value, trie.scan("ad", depth=6, predicate=lambda _: True ))) == [ + ( 'ad', None ), + ( 'add', 10), + ( 'addi', None ), + ( 'addic', None ), + ( 'addict', 11 ), + ( 'addr', None ), + ( 'addre', None ), + ( 'addres', None ), + ( 'address', 12 ), + ( 'adj', None ), + ( 'adju', None ), + ( 'adjus', None ), + ( 'adjust', 13 ), + ] + + # Test 2: Custom TrieNode with different markers + class CustomTrieNode(TrieNode): + EMPTY = "CUSTOM_EMPTY" + + custom_root = CustomTrieNode() + custom_trie = Trie(custom_root) + + # Test that custom markers are used + assert custom_trie.root.EMPTY == "CUSTOM_EMPTY" + + # Insert some words + custom_trie.insert("test", 42) + custom_trie.insert("testing", 99) + + # Verify custom markers are returned for non-existent words + terminal, stem, current = custom_trie.search("nonexistent") + assert not terminal and stem == "" and current is None + terminal, stem, current = custom_trie.search("") + assert not terminal and stem == "" and current.value == "CUSTOM_EMPTY" # Root has EMPTY value + + # Verify normal functionality still works + terminal, stem, current = custom_trie.search("test") + assert terminal and stem == "test" and current.value == 42 + terminal, stem, current = custom_trie.search("testing") + assert terminal and stem == "testing" and current.value == 99 + assert "test" in custom_trie + assert "nonexistent" not in custom_trie + + # Test abbreviation completion with custom markers + terminal, stem, current = custom_trie.search("tes", complete=False) + assert stem == "tes" and current.value == "CUSTOM_EMPTY" # Ambiguous: "test" vs "testing" + assert not terminal + terminal, stem, current = custom_trie.search("tes", complete=True) + assert terminal and stem == "test" and current.value == 42 # Single path to "test" vs "testing" + *_, (_, _, current) = custom_trie.complete(current=current) + assert current.value == 99 # Should carry on completing the single path from "test" to "testing" + terminal, stem, current = custom_trie.search("testin", complete=True) + assert terminal and stem == "testing" and current.value == 99 # Unambiguous: completes to "testing" + + print("✓ Custom TrieNode marker functionality verified!") + print("✓ Design pattern allows for derived TrieNode classes with custom EMPTY values") + if __name__ == "__main__": pytest.main([__file__]) From 410d86a5f507ef66b953529cd8816f611a67d0be Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Fri, 12 Sep 2025 15:19:13 -0600 Subject: [PATCH 19/38] Ensure ambiguity is detected, and resolved w/ preferred language --- hdwallet/mnemonics/imnemonic.py | 16 +++--- tests/test_bip39_cross_language.py | 86 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 46551150..74758151 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -224,8 +224,8 @@ def __getitem__(self, key: Tuple[int, str]): # The key'th word (or IndexError) return self._words[key], key, set() - *_, (_, node) = self._trie.find( key, complete=Trie ) - if node is None: + terminal, prefix, node = self._trie.search( key, complete=Trie ) + if not terminal: # We're nowhere in the Trie with this word raise KeyError(f"{key} does not match any word") @@ -466,7 +466,7 @@ def find_language( language_words_indices: Dict[str, Dict[str, int]] = {} quality: Dict[str, int] = {} # How many language symbols were matched - for candidate, words_list, words_indices in cls.all_wordslist_indices( wordlist_path=wordlist_path ): + for candidate, words_list, words_indices in cls.all_words_indices( wordlist_path=wordlist_path ): language_words_indices[candidate] = words_indices quality[candidate] = 0 try: @@ -475,8 +475,8 @@ def find_language( for word in mnemonic: word_composed = unicodedata.normalize( "NFKC", word ) try: - words_indices[word_composed] - quality[candidate] += len( word_composed ) + word, index, options = words_indices[word_composed] + quality[candidate] += len( word ) except KeyError as ex: raise MnemonicError(f"Unable to find word {word}") from ex @@ -499,9 +499,9 @@ def find_language( if not quality: raise MnemonicError(f"Unrecognized language for mnemonic '{mnemonic}'") - (matches, candidate), *rest = sorted(( (m, c) for c, m in quality.items()), reverse=True ) - if rest and matches == rest[0][0]: - raise MnemonicError(f"Ambiguous language for mnemonic '{mnemonic}'; specify a preferred language") + (candidate, matches), *worse = sorted(quality.items(), key=lambda k_v: k_v[1], reverse=True ) + if worse and matches == worse[0][1]: + raise MnemonicError(f"Ambiguous languages {', '.join(c for c, w in worse)} or {candidate} for mnemonic; specify a preferred language") return language_words_indices[candidate], candidate diff --git a/tests/test_bip39_cross_language.py b/tests/test_bip39_cross_language.py index 1c09db3e..7f8152da 100644 --- a/tests/test_bip39_cross_language.py +++ b/tests/test_bip39_cross_language.py @@ -396,5 +396,91 @@ class CustomTrieNode(TrieNode): print("✓ Design pattern allows for derived TrieNode classes with custom EMPTY values") + def test_ambiguous_languages(self): + """Test that find_language correctly detects and raises errors for ambiguous mnemonics. + + This test verifies that when a mnemonic contains words common to multiple languages + with equal quality scores, find_language raises a MnemonicError indicating the ambiguity. + """ + from hdwallet.exceptions import MnemonicError + + # Create a test mnemonic using only common words between languages + # Use enough words to create realistic test cases + if len(self.common_words) < 12: + pytest.skip(f"Not enough common words ({len(self.common_words)}) for ambiguity testing") + + # Test with 12-word mnemonics using only common words + test_mnemonic = self.common_words[:12] # Use first 12 common words + + # Test 1: find_language should detect ambiguity when no preferred language is specified + try: + word_indices, detected_language = BIP39Mnemonic.find_language(test_mnemonic) + # If this succeeds, it means one language had a higher quality score than others + # This is valid behavior - not all common word combinations are equally ambiguous + print(f"Mnemonic resolved to {detected_language} (quality was decisive)") + except MnemonicError as e: + # This is the expected behavior for truly ambiguous mnemonics + assert "Ambiguous languages" in str(e), f"Expected ambiguity error, got: {e}" + assert "specify a preferred language" in str(e), f"Expected preference suggestion, got: {e}" + print(f"✓ Correctly detected ambiguous mnemonic: {e}") + + # Test 2: Verify that specifying a preferred language resolves the ambiguity + # Try with each available language that contains these common words + resolved_languages = [] + for language in ['english', 'french']: # Test both languages we know have common words + try: + word_indices, detected_language = BIP39Mnemonic.find_language( + test_mnemonic, language=language + ) + resolved_languages.append(detected_language) + print(f"✓ Successfully resolved with preferred language '{language}' -> {detected_language}") + except MnemonicError as e: + print(f"Failed to resolve with language '{language}': {e}") + + # At least one language should successfully resolve the mnemonic + assert len(resolved_languages) > 0, "No language could resolve the test mnemonic" + + # Test 3: Test with a different set of common words to ensure robustness + if len(self.common_words) >= 24: + # Try with different common words (offset by 6 to get different words) + alt_test_mnemonic = self.common_words[6:18] # Words 6-17 (12 words) + + try: + word_indices, detected_language = BIP39Mnemonic.find_language(alt_test_mnemonic) + print(f"Alternative mnemonic resolved to {detected_language}") + except MnemonicError as e: + if "Ambiguous languages" in str(e): + print(f"✓ Alternative mnemonic also correctly detected as ambiguous: {e}") + # Test that preferred language resolves it + word_indices, detected_language = BIP39Mnemonic.find_language( + alt_test_mnemonic, language='english' + ) + print(f"✓ Alternative mnemonic resolved with preferred language: {detected_language}") + else: + raise # Re-raise unexpected errors + + # Test 4: Verify behavior with abbreviations if common abbreviations exist + if len(self.common_abbrevs) >= 12: + abbrev_mnemonic = list(self.common_abbrevs)[:12] + print(f"Testing with common abbreviations: {abbrev_mnemonic[:5]}...") + + try: + word_indices, detected_language = BIP39Mnemonic.find_language(abbrev_mnemonic) + print(f"Abbreviation mnemonic resolved to {detected_language}") + except MnemonicError as e: + if "Ambiguous languages" in str(e): + print(f"✓ Abbreviation mnemonic correctly detected as ambiguous") + # Verify preferred language resolves it + word_indices, detected_language = BIP39Mnemonic.find_language( + abbrev_mnemonic, language='english' + ) + print(f"✓ Abbreviation mnemonic resolved with preferred language: {detected_language}") + else: + raise # Re-raise unexpected errors + + print("✓ Ambiguous language detection tests completed successfully") + print(f"✓ Tested with {len(test_mnemonic)} common words") + print("✓ Verified ambiguity detection and preferred language resolution") + if __name__ == "__main__": pytest.main([__file__]) From 2722610eeb703554d016dcfbb93b83d64328779c Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Tue, 16 Sep 2025 06:02:14 -0600 Subject: [PATCH 20/38] Rendering of Trie, work toward korean language support --- hdwallet/mnemonics/bip39/mnemonic.py | 28 +-- hdwallet/mnemonics/imnemonic.py | 159 +++++++++---- hdwallet/seeds/bip39.py | 4 +- .../mnemonics/test_mnemonics_bip39.py | 10 +- tests/test_bip39_cross_language.py | 16 +- tests/test_bip39_normalization.py | 210 ------------------ 6 files changed, 141 insertions(+), 286 deletions(-) delete mode 100644 tests/test_bip39_normalization.py diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index dcfb4144..64ac3e85 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Union, Dict, List, Optional + Union, Dict, List, Mapping, Optional ) from ...entropies import ( @@ -208,6 +208,8 @@ def encode(cls, entropy: Union[str, bytes], language: str) -> str: This method converts a given entropy value into a mnemonic phrase according to the specified language. + It is NFC normalized for presentation, and must be NFKD normalized before conversion to a BIP-39 seed. + :param entropy: The entropy to encode into a mnemonic phrase. :type entropy: Union[str, bytes] :param language: The language for the mnemonic phrase. @@ -239,16 +241,15 @@ def encode(cls, entropy: Union[str, bytes], language: str) -> str: word_index: int = binary_string_to_integer(word_bin) mnemonic.append(words_list[word_index]) - return " ".join(mnemonic) # Words from wordlist are already properly normalized + return " ".join(mnemonic) # Words from wordlist are already normalized NFD for encoding @classmethod def decode( cls, mnemonic: str, - language: Optional[str], + language: Optional[str] = None, checksum: bool = False, - words_list: Optional[List[str]] = None, - words_list_with_index: Optional[Dict[str, int]] = None, + words_list_with_index: Optional[Mapping[str, int]] = None, ) -> str: """ Decodes a mnemonic phrase into its corresponding entropy. @@ -271,22 +272,17 @@ def decode( :rtype: str """ - words: list = cls.normalize(mnemonic, language=language) + words: list = cls.normalize(mnemonic) if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - if not words_list or not words_list_with_index: + if not words_list_with_index: words_list_with_index, language = cls.find_language(mnemonic=words, language=language) if len(set(words_list_with_index.values())) != cls.words_list_number: raise Error( "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) ) - if len(words_list) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) - ) - mnemonic_bin: str = "".join(map( lambda word: integer_to_binary_string( words_list_with_index[word], cls.word_bit_length @@ -323,8 +319,8 @@ def decode( def is_valid( cls, mnemonic: Union[str, List[str]], - words_list: Optional[List[str]] = None, - words_list_with_index: Optional[dict] = None + language: Optional[str] = None, + words_list_with_index: Optional[Mapping[str, int]] = None ) -> bool: """ Validates a mnemonic phrase. @@ -344,9 +340,7 @@ def is_valid( """ try: - cls.decode( - mnemonic=mnemonic, words_list=words_list, words_list_with_index=words_list_with_index - ) + import unicodedata return True except (Error, KeyError): return False diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 74758151..187d644e 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -7,11 +7,11 @@ from abc import ( ABC, abstractmethod ) -from collections.abc import ( - Mapping +from collections import ( + abc ) from typing import ( - Any, Callable, Union, Dict, Generator, List, Set, Tuple, Optional + Any, Callable, Dict, Generator, List, Mapping, Optional, Set, Tuple, Union ) import os @@ -162,6 +162,48 @@ def scan( for suffix, found in self.scan( current=child, depth=max(0, depth-1), predicate=predicate ): yield prefix + char + suffix, found + def dump_lines( + self, + current: Optional[TrieNode] = None, + indent: int = 6, + level: int = 0 + ) -> List[str]: + """Output the Trie and its mapped values in a human-comprehensible form.""" + if current is None: + current = self.root + + # There can be multiple routes to the same child (ie. glyphs with/without marks) + kids = defaultdict(set) + for char, child in current.children.items(): + kids[child].add(char) + + result = [] + if kids and current.value != current.EMPTY: + # The present node is both a terminal word (eg. "add"), AND has children (eg. "addict", ...) + result = [ "" ] + for i, (child, chars) in enumerate(kids.items()): + first, *rest = self.dump_lines( child, indent=indent, level=level+1 ) + result.append( f"{' ' * (bool(result) or bool(i)) * level * indent}{'/'.join( chars ):{indent}}{first}" ) + result.extend( rest ) + + if not result: + # No kids AND current value == current.EMPTY! This is a degenerate Trie, but support it. + result = [""] + if current.value != current.EMPTY: + result[0] += f"{' ' * max(0, 10 - level) * indent} == {current.value}" + return result + + def dump( + self, + current: Optional[TrieNode] = None, + indent: int = 6, + level: int = 0 + ) -> str: + return '\n'.join(self.dump_lines( current=current, indent=indent, level=level )) + + def __str__(self): + return self.dump() + def unmark( word_composed: str ) -> str: """This word may contain composite characters with accents like "é" that decompose "e" + "'". @@ -176,12 +218,11 @@ def unmark( word_composed: str ) -> str: ) -class WordIndices( Mapping ): - """Holds a Sequence of Mnemonic words, and is indexable either by int (returning the original - word), or by the original word (with or without Unicode "Marks") or a unique abbreviations, - returning the int index. +class WordIndices( abc.Mapping ): + """A Mapping which holds a Sequence of Mnemonic words. - For non-unique prefixes, indexing returns a Set[str] of next character options. + Indexable either by int (returning the original word), or by the original word (with or without + Unicode "Marks") or a unique abbreviations, returning the int index. """ def __init__(self, sequence): @@ -193,25 +234,38 @@ def __init__(self, sequence): self._words = [] for i, word in enumerate( sequence ): self._words.append( word ) - self._trie.insert( word, i ) - word_unmarked = unmark( word ) + + self._trie.insert( word_unmarked, i ) + if word == word_unmarked or len( word ) != len( word_unmarked ): # If the word has no marks, or if the unmarked word doesn't have the same number of # glyphs, we can't "alias" it. - #print( f"Not inserting {word!r}; unmarked {word_unmarked!r} identical or incompatible" ) continue - # Traverse the TrieNodes representing 'word'. Each character in word and word_unmarked - # is joined by the TrieNode which contains it in .children, and we should never get a - # None (lose the plot) because we've just inserted 'word'! This will just be idempotent - # unless c_u != c. - #print( f"Inserting {word:10} alias: {word_unmarked}" ) - for c, c_u, (_, _, n) in zip( word, word_unmarked, self._trie.find( word )): - assert c in n.children - n.children[c_u] = n.children[c] - - def __getitem__(self, key: Tuple[int, str]): - """A Mapping to find a word by spelling or index, returning a value Tuple consisting of: + + # Traverse the TrieNodes representing 'word_unmarked'. Each glyph in word and + # word_unmarked is joined by the TrieNode which contains it in .children, and we should + # never get a None (lose the plot) because we've just inserted 'word'! This will + # "alias" each glyph with a mark, to the .children entry for the non-marked glyph. + for c, c_un, (_, _, n) in zip( word, word_unmarked, self._trie.find( word )): + if c != c_un: + if c in n.children and c_un in n.children: + assert n.children[c_un] is n.children[c], \ + f"Attempting to alias {c_un!r} to {c!r} but already exists as a non-alias" + n.children[c] = n.children[c_un] + + def __getitem__(self, key: Union[str, int]) -> int: + """A Mapping from "word" to index, or the reverse. + + Any unique abbreviation with/without UTF-8 "Marks" is accepted. We keep this return value + simple, to make WordIndices work similarly to a Dict[str, int] of mnemonic word/index pairs. + + """ + word, index, _ = self.get_details(key) + return index if isinstance( key, str ) else word + + def get_details(self, key: Union[int, str]) -> Tuple[str, int, Set[str]]: + """Provide a word (or unique prefix) or an index, and returns a value Tuple consisting of: - The canonical word 'str', and - The index value, and - the set of options available from the end of word, if any @@ -227,12 +281,12 @@ def __getitem__(self, key: Tuple[int, str]): terminal, prefix, node = self._trie.search( key, complete=Trie ) if not terminal: # We're nowhere in the Trie with this word - raise KeyError(f"{key} does not match any word") + raise KeyError(f"{key!r} does not match any word") return self._words[node.value], node.value, set(node.children) def __len__(self): - return self._words + return len( self._words ) def __iter__(self): return iter( self._words ) @@ -247,8 +301,11 @@ def items(self): return zip( self._words, self.values() ) def abbreviations(self): - """All unique abbreviations of words in the Trie. Scans the Trie, identifying each prefix - that uniquely abbreviates a word.""" + """All unique abbreviations of words in the Trie. + + Scans the Trie, identifying each prefix that uniquely abbreviates a word. + + """ def unique( current ): terminal = False for terminal, _, complete in self._trie.complete( current ): @@ -260,6 +317,9 @@ def unique( current ): # Only abbreviations (not terminal words) that led to a unique terminal word yield abbrev + def __str__(self): + return str(self._trie) + class IMnemonic(ABC): @@ -363,8 +423,9 @@ def get_words_list_by_language( ) -> List[str]: """Retrieves the standardized (NFC normalized, lower-cased) word list for the specified language. - Uses NFC normalization for internal processing consistency. BIP-39 wordlists are stored in NFD - format but we normalize to NFC for internal word comparisons and lookups. + Uses NFC normalization for internal processing consistency. BIP-39 wordlists are generally + stored in NFD format (with some exceptions like russian) but we normalize to NFC for + internal word comparisons and lookups, and for display. We do not want to use 'normalize' to do this, because normalization of Mnemonics may have additional functionality beyond just ensuring symbol and case standardization. @@ -380,16 +441,21 @@ def get_words_list_by_language( """ wordlist_path = cls.wordlist_path if wordlist_path is None else wordlist_path + words_list: List[str] = [] with open(os.path.join(os.path.dirname(__file__), wordlist_path[language]), "r", encoding="utf-8") as fin: - words_list: List[str] = [ - unicodedata.normalize("NFC", word.lower()) - for word in map(str.strip, fin.readlines()) - if word and not word.startswith("#") - ] + for word in map( str.lower, map( str.strip, fin )): + if not word or word.startswith("#"): + continue + word_nfc = unicodedata.normalize("NFKC", word) + word_nfkd = unicodedata.normalize( "NFKD", word_nfc) + assert word == word_nfkd or word == word_nfc, \ + f"Original {language} word {word!r} failed to round-trip through NFC: {word_nfc!r} / NFKD: {word_nfkd!r}" + words_list.append(word_nfc) + return words_list @classmethod - def all_words_indices( + def language_words_indices( cls, wordlist_path: Optional[Dict[str, str]] = None ) -> Tuple[str, List[str], WordIndices]: """Yields each 'candidate' language, its NFKC-normalized words List, and its WordIndices @@ -402,7 +468,6 @@ def all_words_indices( words_list: List[str] = cls.get_words_list_by_language( language=candidate, wordlist_path=wordlist_path ) - print( f"Language {candidate!r} has {len(words_list)} words" ) word_indices = WordIndices( words_list ) yield candidate, words_list, word_indices @@ -412,7 +477,7 @@ def find_language( mnemonic: List[str], wordlist_path: Optional[Dict[str, str]] = None, language: Optional[str] = None, - ) -> Tuple[Dict[str, int], str]: + ) -> Tuple[Mapping[str, int], str]: """Finds the language of the given mnemonic by checking against available word list(s), preferring the specified 'language' if one is supplied. If a 'wordlist_path' dict of {language: path} is supplied, its languages are used. If a 'language' (optional) is @@ -436,7 +501,7 @@ def find_language( a different seed and therefore different derived wallets -- a match to multiple languages with the same quality and with no preferred 'language' leads to an Exception. - Even the final word (whchi encodes some checksum bits) cannot determine the language with + Even the final word (which encodes some checksum bits) cannot determine the language with finality, because it is only a statistical checksum! For 128-bit 12-word encodings, only 4 bits of checksum are represented. Therefore, there is a 1/16 chance that any entropy that encodes to words in both languages will *also* have the same 4 bits of checksum! 24-word @@ -448,7 +513,7 @@ def find_language( cryptographic keys. - The returned Dict[str, int] contains all accepted word -> index mappings, including all + The returned Mapping[str, int] contains all accepted word -> index mappings, including all acceptable abbreviations, with and without character accents. This is typically the expected behavior for most Mnemonic encodings ('café' == 'cafe' for Mnemonic word matching). @@ -460,25 +525,25 @@ def find_language( :type mnemonic: Optional[str] :return: A tuple containing the language's word indices and the language name. - :rtype: Tuple[Dict[str, int], str] + :rtype: Tuple[[str, int], str] """ - language_words_indices: Dict[str, Dict[str, int]] = {} - quality: Dict[str, int] = {} # How many language symbols were matched - for candidate, words_list, words_indices in cls.all_words_indices( wordlist_path=wordlist_path ): - language_words_indices[candidate] = words_indices - quality[candidate] = 0 + language_indices: Dict[str, Mapping[str, int]] = {} + quality: Dict[str, int] = defaultdict(int) # How many language symbols were matched + for candidate, words_list, words_indices in cls.language_words_indices( wordlist_path=wordlist_path ): + language_indices[candidate] = words_indices try: # Check for exact matches and unique abbreviations, ensuring comparison occurs in # composite "NFKC" normalized characters. for word in mnemonic: word_composed = unicodedata.normalize( "NFKC", word ) try: - word, index, options = words_indices[word_composed] - quality[candidate] += len( word ) + index = words_indices[word_composed] except KeyError as ex: raise MnemonicError(f"Unable to find word {word}") from ex + word_canonical = words_indices[index] + quality[candidate] += len( word_canonical ) if candidate == language: # All words exactly matched word with or without accents, complete or uniquely @@ -503,7 +568,7 @@ def find_language( if worse and matches == worse[0][1]: raise MnemonicError(f"Ambiguous languages {', '.join(c for c, w in worse)} or {candidate} for mnemonic; specify a preferred language") - return language_words_indices[candidate], candidate + return language_indices[candidate], candidate @classmethod diff --git a/hdwallet/seeds/bip39.py b/hdwallet/seeds/bip39.py index 683f0dc9..de0de911 100644 --- a/hdwallet/seeds/bip39.py +++ b/hdwallet/seeds/bip39.py @@ -74,8 +74,8 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str if not BIP39Mnemonic.is_valid(mnemonic=mnemonic): raise MnemonicError(f"Invalid {cls.name()} mnemonic words") - # Normalize mnemonic to NFD for seed generation as required by BIP-39 specification - normalized_mnemonic: str = BIP39Mnemonic.normalize_for_seed(mnemonic) + # Normalize mnemonic to NFKD for seed generation as required by BIP-39 specification + normalized_mnemonic: str = unicodedata.normalize("NFKD", mnemonic) # Salt normalization should use NFKD as per BIP-39 specification salt: str = unicodedata.normalize("NFKD", ( diff --git a/tests/hdwallet/mnemonics/test_mnemonics_bip39.py b/tests/hdwallet/mnemonics/test_mnemonics_bip39.py index 3d3b4b7c..e2b66ca1 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_bip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_bip39.py @@ -8,6 +8,7 @@ import json import os import pytest +import unicodedata from hdwallet.mnemonics.bip39.mnemonic import ( BIP39Mnemonic, BIP39_MNEMONIC_LANGUAGES, BIP39_MNEMONIC_WORDS @@ -43,15 +44,18 @@ def test_bip39_mnemonics(data): assert BIP39Mnemonic.is_valid_words(words=__["words"]) for language in __["languages"].keys(): - assert BIP39Mnemonic.is_valid_language(language=language) - assert BIP39Mnemonic.is_valid(mnemonic=__["languages"][language]) + # A BIP-39 Mnemonic must have a preferred language, to be deterministically decoded in all cases. + print( f"BIP39 {language} Mnemonic: {BIP39Mnemonic.normalize(__['languages'][language])}" ) + assert BIP39Mnemonic.is_valid(mnemonic=__["languages"][language], language=language) mnemonic = BIP39Mnemonic.from_words(words=__["words"], language=language) assert len(mnemonic.split()) == __["words"] assert BIP39Mnemonic(mnemonic=mnemonic).language().lower() == language - assert BIP39Mnemonic.from_entropy(entropy=__["entropy"], language=language) == __["languages"][language] + # We assume NF[K]C encoding for Mnemonics we generate/normalize, so ensure that the + # reference mnemonic is in the same form + assert BIP39Mnemonic.from_entropy(entropy=__["entropy"], language=language) == unicodedata.normalize("NFKC", __["languages"][language]) assert BIP39Mnemonic.decode(mnemonic=__["languages"][language]) == __["entropy"] mnemonic = BIP39Mnemonic(mnemonic=__["languages"][language]) diff --git a/tests/test_bip39_cross_language.py b/tests/test_bip39_cross_language.py index 7f8152da..d14478a5 100644 --- a/tests/test_bip39_cross_language.py +++ b/tests/test_bip39_cross_language.py @@ -9,7 +9,7 @@ from hdwallet.mnemonics.bip39 import BIP39Mnemonic from hdwallet.mnemonics.imnemonic import Trie, TrieNode - +from hdwallet.exceptions import ChecksumError class TestBIP39CrossLanguage: """Test BIP39 mnemonics that work in both English and French languages. @@ -66,11 +66,11 @@ def setup_class(cls, languages: list[str] = None): # Load all specified languages language_data = {} - for language, words, words_indices in BIP39Mnemonic.all_words_indices(): + for language, words, indices in BIP39Mnemonic.language_words_indices(): language_data[language] = dict( words = words, - indices = words_indices, - abbrevs = set( words_indices.abbreviations() ), + indices = indices, + abbrevs = set( indices.abbreviations() ), ) if language not in languages: continue @@ -93,9 +93,9 @@ def setup_class(cls, languages: list[str] = None): print(f"{lang.capitalize()} words: {len(data['words'])}") print(f"Common words found: {len(cls.common_words)}") - print(f"First 20 common words: {cls.common_words[:20]}") + print(f"First 20 common words: {sorted(cls.common_words)[:20]}") print(f"Common abbrevs found: {len(cls.common_abbrevs)}") - print(f"First 20 common abbrevs: {cls.common_abbrevs[:20]}") + print(f"First 20 common abbrevs: {sorted(cls.common_abbrevs)[:20]}") def create_random_mnemonic_from_common_words(self, word_count: int) -> str: """Create a random mnemonic using only common words.""" @@ -127,8 +127,10 @@ def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_att successful_both_languages.append(mnemonic) print(f"{words}-word common mnemonics {' '.join(mnemonic)!r}") - except Exception as exc: + except ChecksumError as exc: # Skip invalid mnemonics (e.g., checksum failures) + #import traceback + #print( f"Failed to decode: {traceback.format_exc()}" ) continue success_rate = len(successful_both_languages) / total_attempts diff --git a/tests/test_bip39_normalization.py b/tests/test_bip39_normalization.py deleted file mode 100644 index b593aac1..00000000 --- a/tests/test_bip39_normalization.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 - -import unicodedata -import pytest - -from hdwallet.mnemonics.bip39 import BIP39Mnemonic -from hdwallet.seeds.bip39 import BIP39Seed - - -class TestBIP39Normalization: - """Test BIP-39 normalization implementation fixes. - - This test validates that the normalization changes work correctly: - 1. User input uses NFKC -> NFC normalization (handles compatibility characters) - 2. Internal processing uses NFC normalization (consistent word comparisons) - 3. Seed generation uses NFD normalization (BIP-39 specification requirement) - """ - - @classmethod - def setup_class(cls): - """Set up test cases with different Unicode representations.""" - - # Test mnemonic with accented characters in different Unicode forms - cls.test_mnemonics = { - # French mnemonic with é in different forms - 'composed': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon café', # é as U+00E9 - 'decomposed': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon cafe\u0301', # e + U+0301 - - # Spanish mnemonic with ñ in different forms - 'composed_spanish': 'ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco niño', # ñ as U+00F1 - 'decomposed_spanish': 'ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco ábaco nin\u0303o', # n + U+0303 - } - - # User input scenarios with compatibility characters - cls.compatibility_scenarios = { - # Fullwidth characters (common with Asian keyboards) - 'fullwidth': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon café', - # Ligature characters - 'ligature': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon cafie', # contains fi ligature - # Roman numeral (though unlikely in real mnemonics) - 'roman': 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon cafeⅠ', - } - - def test_user_input_normalization(self): - """Test that user input normalization handles compatibility characters correctly.""" - - # Test NFKC -> NFC normalization for user input - test_cases = [ - # Fullwidth input should normalize to regular characters - ('abandon', 'abandon'), - # Ligature input should decompose - ('file', 'file'), # fi ligature -> fi - # Mixed case should be lowercased - ('ABANDON', 'abandon'), - ] - - for input_word, expected in test_cases: - result = BIP39Mnemonic.normalize_user_input([input_word]) - assert result == [expected], f"Failed to normalize '{input_word}' to '{expected}', got {result}" - - def test_wordlist_normalization(self): - """Test that wordlists are properly normalized to NFC for internal processing.""" - - # Load French wordlist (contains accented characters) - french_words = BIP39Mnemonic.get_words_list_by_language('french') - - # Check that all words are in NFC form - for word in french_words[:10]: # Test first 10 words - nfc_form = unicodedata.normalize('NFC', word) - assert word == nfc_form, f"Word '{word}' is not in NFC form, got '{nfc_form}'" - - # Check specific accented words - accented_words = [word for word in french_words if any(ord(c) > 127 for c in word)][:5] - for word in accented_words: - # Verify it's in NFC form (shorter than NFD due to composed characters) - nfd_form = unicodedata.normalize('NFD', word) - assert len(word) < len(nfd_form), f"Word '{word}' doesn't appear to be in NFC form" - - def test_mnemonic_validation_consistency(self): - """Test that mnemonics with different Unicode representations validate consistently.""" - - # These should all represent the same semantic mnemonic - composed_mnemonic = self.test_mnemonics['composed'] - decomposed_mnemonic = self.test_mnemonics['decomposed'] - - # Both should be valid (after normalization) - assert BIP39Mnemonic.is_valid(composed_mnemonic), "Composed mnemonic should be valid" - assert BIP39Mnemonic.is_valid(decomposed_mnemonic), "Decomposed mnemonic should be valid" - - # Both should normalize to the same internal representation - composed_normalized = BIP39Mnemonic.normalize(composed_mnemonic) - decomposed_normalized = BIP39Mnemonic.normalize(decomposed_mnemonic) - assert composed_normalized == decomposed_normalized, "Different Unicode forms should normalize to same result" - - def test_seed_generation_normalization(self): - """Test that seed generation uses NFD normalization as required by BIP-39.""" - - # Use a known valid mnemonic - test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - - # Verify the mnemonic is valid - assert BIP39Mnemonic.is_valid(test_mnemonic), "Test mnemonic should be valid" - - # Test normalize_for_seed method - normalized_for_seed = BIP39Mnemonic.normalize_for_seed(test_mnemonic) - - # Should be in NFD form - nfd_form = unicodedata.normalize('NFD', test_mnemonic) - assert normalized_for_seed == nfd_form, "normalize_for_seed should return NFD form" - - # Generate seed to verify it works - seed = BIP39Seed.from_mnemonic(test_mnemonic) - assert len(seed) == 128, "BIP39 seed should be 128 hex characters (64 bytes)" - - def test_seed_generation_with_accents(self): - """Test seed generation with accented characters uses proper NFD normalization.""" - - # Test with French mnemonic containing accents - # This is a crafted example - real French mnemonics would need proper checksum - french_test = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon café" - - if BIP39Mnemonic.is_valid(french_test): - # Test that different Unicode representations produce the same seed - composed = french_test # é as single codepoint - decomposed = french_test.replace('café', 'cafe\u0301') # e + combining accent - - seed1 = BIP39Seed.from_mnemonic(composed) - seed2 = BIP39Seed.from_mnemonic(decomposed) - - assert seed1 == seed2, "Different Unicode representations should produce the same seed" - - def test_compatibility_character_handling(self): - """Test that compatibility characters in user input are handled correctly.""" - - # Test fullwidth characters (common with Asian keyboards) - fullwidth_input = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - normal_input = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - - # Normalize both - fullwidth_normalized = BIP39Mnemonic.normalize(fullwidth_input) - normal_normalized = BIP39Mnemonic.normalize(normal_input) - - # Should produce the same result - assert fullwidth_normalized == normal_normalized, "Fullwidth input should normalize to same result as normal input" - - # Both should be valid - assert BIP39Mnemonic.is_valid(fullwidth_input), "Fullwidth input should be valid after normalization" - assert BIP39Mnemonic.is_valid(normal_input), "Normal input should be valid" - - def test_normalization_methods_consistency(self): - """Test that different normalization methods work consistently.""" - - test_input = "abandon café" # Mix of ASCII and accented - - # Test normalize vs normalize_user_input - normalized = BIP39Mnemonic.normalize(test_input) - user_normalized = BIP39Mnemonic.normalize_user_input(test_input) - - assert normalized == user_normalized, "normalize() and normalize_user_input() should produce same result" - - # Test normalize_for_seed - seed_normalized = BIP39Mnemonic.normalize_for_seed(test_input) - - # Should be different (NFD vs NFC) - input_nfc = unicodedata.normalize('NFC', test_input) - input_nfd = unicodedata.normalize('NFD', test_input) - - assert seed_normalized == input_nfd, "normalize_for_seed should return NFD form" - assert " ".join(normalized) != seed_normalized, "Internal normalization should differ from seed normalization" - - def test_edge_cases(self): - """Test edge cases and error conditions.""" - - # Empty string - assert BIP39Mnemonic.normalize("") == [] - assert BIP39Mnemonic.normalize([]) == [] - - # Single word - single_word = BIP39Mnemonic.normalize("abandon") - assert single_word == ["abandon"] - - # Mixed case with accents - mixed_case = BIP39Mnemonic.normalize("CAFÉ") - assert mixed_case == ["café"] - - # Extra whitespace - whitespace_test = BIP39Mnemonic.normalize(" abandon about ") - assert whitespace_test == ["abandon", "about"] - - def test_backwards_compatibility(self): - """Test that changes don't break existing functionality.""" - - # Test standard English mnemonic (no accents, should work as before) - english_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - - # Should be valid - assert BIP39Mnemonic.is_valid(english_mnemonic) - - # Should generate consistent seed - seed = BIP39Seed.from_mnemonic(english_mnemonic) - assert isinstance(seed, str) - assert len(seed) == 128 - - # Normalized forms should be consistent - normalized = BIP39Mnemonic.normalize(english_mnemonic) - assert all(isinstance(word, str) for word in normalized) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file From 2b46f39f9a9a0521bda37b7266652f200dc3e5c9 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Tue, 16 Sep 2025 16:33:03 -0600 Subject: [PATCH 21/38] Passing BIP-39 unit tests --- hdwallet/mnemonics/bip39/mnemonic.py | 33 +--- hdwallet/mnemonics/imnemonic.py | 57 ++++-- tests/data/json/mnemonics.json | 36 +++- .../mnemonics/test_mnemonics_bip39.py | 25 ++- tests/test_bip39_cross_language.py | 174 ++++++++++++++---- tests/test_unicode_normalization.py | 78 ++++---- 6 files changed, 270 insertions(+), 133 deletions(-) diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index 64ac3e85..09adb0b7 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -241,7 +241,8 @@ def encode(cls, entropy: Union[str, bytes], language: str) -> str: word_index: int = binary_string_to_integer(word_bin) mnemonic.append(words_list[word_index]) - return " ".join(mnemonic) # Words from wordlist are already normalized NFD for encoding + # Words from wordlist are normalized NFC for display + return " ".join(mnemonic) @classmethod def decode( @@ -314,33 +315,3 @@ def decode( binary_string_to_bytes(mnemonic_bin, pad_bit_len // 4) ) return bytes_to_string(entropy) - - @classmethod - def is_valid( - cls, - mnemonic: Union[str, List[str]], - language: Optional[str] = None, - words_list_with_index: Optional[Mapping[str, int]] = None - ) -> bool: - """ - Validates a mnemonic phrase. - - This method checks whether the provided mnemonic phrase is valid by attempting to decode it. - If the decoding is successful without raising any errors, the mnemonic is considered valid. - - :param mnemonic: The mnemonic phrase to validate. It can be a string or a list of words. - :type mnemonic: Union[str, List[str]] - :param words_list: Optional list of words to be used for validation. If not provided, the method will use the default word list. - :type words_list: Optional[List[str]] - :param words_list_with_index: Optional dictionary mapping words to their indices for validation. If not provided, the method will use the default mapping. - :type words_list_with_index: Optional[dict] - - :return: True if the mnemonic phrase is valid, False otherwise. - :rtype: bool - """ - - try: - import unicodedata - return True - except (Error, KeyError): - return False diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 187d644e..18b49a98 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -236,17 +236,17 @@ def __init__(self, sequence): self._words.append( word ) word_unmarked = unmark( word ) - self._trie.insert( word_unmarked, i ) - if word == word_unmarked or len( word ) != len( word_unmarked ): # If the word has no marks, or if the unmarked word doesn't have the same number of - # glyphs, we can't "alias" it. + # glyphs, we can't "alias" it; insert the original word with NFC "combined" glyphs. + self._trie.insert( word, i ) continue # Traverse the TrieNodes representing 'word_unmarked'. Each glyph in word and # word_unmarked is joined by the TrieNode which contains it in .children, and we should # never get a None (lose the plot) because we've just inserted 'word'! This will # "alias" each glyph with a mark, to the .children entry for the non-marked glyph. + self._trie.insert( word_unmarked, i ) for c, c_un, (_, _, n) in zip( word, word_unmarked, self._trie.find( word )): if c != c_un: if c in n.children and c_un in n.children: @@ -300,8 +300,13 @@ def values(self): def items(self): return zip( self._words, self.values() ) + def unique(self): + """All full unique words in the Trie, with/without UTF-8 Marks.""" + for word, _node in self._trie.scan(): + yield word + def abbreviations(self): - """All unique abbreviations of words in the Trie. + """All unique abbreviations of words in the Trie, with/without UTF-8 Marks. Scans the Trie, identifying each prefix that uniquely abbreviates a word. @@ -336,8 +341,10 @@ class IMnemonic(ABC): wordlist_path: Dict[str, str] def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: - """ - Initialize an instance of IMnemonic with a mnemonic. + """Initialize an instance of IMnemonic with a mnemonic. + + Converts the provided Mnemonics (abbreviated or missing UTF-8 Marks) to canonical Mnemonic + words in display-able UTF-8 "NFC" form. :param mnemonic: The mnemonic to initialize with, which can be a string or a list of strings. :type mnemonic: Union[str, List[str]] @@ -345,16 +352,24 @@ def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: :return: No return :rtype: NoneType + """ - self._mnemonic: List[str] = self.normalize(mnemonic) - if not self.is_valid(self._mnemonic, **kwargs): - raise MnemonicError("Invalid mnemonic words") - # Attempt to unambiguously determine the Mnemonic's language using the preferred 'language' - # optionally provided. - self._word_indices, self._language = self.find_language(self._mnemonic, language=kwargs.get("language")) + mnemonic_list: List[str] = self.normalize(mnemonic) + # Attempt to unambiguously determine the Mnemonic's language using any preferred 'language' + self._word_indices, self._language = self.find_language(mnemonic_list, language=kwargs.get("language")) self._mnemonic_type = kwargs.get("mnemonic_type", None) + # We now know with certainty that the list of Mnemonic words was valid in some language. + # However, they may have been abbreviations, or had optional UTF-8 Marks removed. So, use + # the _word_indices twice, to map from str (matching word) -> int (index) -> str (canonical + self._mnemonic: List[str] = [ + self._word_indices[self._word_indices[w]] + for w in mnemonic_list + ] self._words = len(self._mnemonic) + # We have the canonical Mnemonic words. Decode them, preserving the real MnemonicError + # details if the words do not form a valid Mnemonic. + self.decode(self._mnemonic, **kwargs) @classmethod def name(cls) -> str: @@ -455,7 +470,7 @@ def get_words_list_by_language( return words_list @classmethod - def language_words_indices( + def wordlist_indices( cls, wordlist_path: Optional[Dict[str, str]] = None ) -> Tuple[str, List[str], WordIndices]: """Yields each 'candidate' language, its NFKC-normalized words List, and its WordIndices @@ -463,7 +478,7 @@ def language_words_indices( unique abbreviations. """ - for candidate in wordlist_path.keys() if wordlist_path else cls.languages: + for candidate in (wordlist_path.keys() if wordlist_path else cls.languages): # Normalized NFC, so characters and accents are combined words_list: List[str] = cls.get_words_list_by_language( language=candidate, wordlist_path=wordlist_path @@ -531,7 +546,7 @@ def find_language( language_indices: Dict[str, Mapping[str, int]] = {} quality: Dict[str, int] = defaultdict(int) # How many language symbols were matched - for candidate, words_list, words_indices in cls.language_words_indices( wordlist_path=wordlist_path ): + for candidate, words_list, words_indices in cls.wordlist_indices( wordlist_path=wordlist_path ): language_indices[candidate] = words_indices try: # Check for exact matches and unique abbreviations, ensuring comparison occurs in @@ -541,7 +556,9 @@ def find_language( try: index = words_indices[word_composed] except KeyError as ex: - raise MnemonicError(f"Unable to find word {word}") from ex + if candidate in quality: + quality.pop(candidate) + raise MnemonicError(f"Unable to find word {word} in {candidate}") from ex word_canonical = words_indices[index] quality[candidate] += len( word_canonical ) @@ -566,18 +583,22 @@ def find_language( (candidate, matches), *worse = sorted(quality.items(), key=lambda k_v: k_v[1], reverse=True ) if worse and matches == worse[0][1]: + # There are more than one matching candidate languages -- and they are both equivalent + # in quality. We cannot know (or guess) the language with any certainty. raise MnemonicError(f"Ambiguous languages {', '.join(c for c, w in worse)} or {candidate} for mnemonic; specify a preferred language") return language_indices[candidate], candidate @classmethod - def is_valid(cls, mnemonic: Union[str, List[str]], **kwargs) -> bool: + def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = None, **kwargs) -> bool: """ Checks if the given mnemonic is valid. :param mnemonic: The mnemonic to check. :type mnemonic: str + :param language: The preferred language of the mnemonic. + :type mnemonic: str :param kwargs: Additional keyword arguments. :return: True if the strength is valid, False otherwise. @@ -585,7 +606,7 @@ def is_valid(cls, mnemonic: Union[str, List[str]], **kwargs) -> bool: """ try: - cls.decode(mnemonic=mnemonic, **kwargs) + cls.decode(mnemonic=mnemonic, language=language, **kwargs) return True except (ValueError, MnemonicError): return False diff --git a/tests/data/json/mnemonics.json b/tests/data/json/mnemonics.json index 933c84f1..c6cd5146 100644 --- a/tests/data/json/mnemonics.json +++ b/tests/data/json/mnemonics.json @@ -104,7 +104,39 @@ "spanish": "zona rama nuca carbón atar artista meta funda avena sonido baile educar ostra ruido juzgar chivo octavo lector brillo ingenio hora yeso sonido helio", "turkish": "zorlu siroz paraşüt defter bavul baca obez kapı beyoğlu tesir bodrum gusül polat tabaka masum doruk peçete mercek coğrafya liyakat kurmay zihinsel tesir köstebek" } - } + }, + { + "name": "BIP39", + "entropy": "98e612eaa0de01e80d1f99be9d52be46", + "words": 12, + "languages": { + "english": "ocean correct rival double theme village crucial veteran salon tunnel question minute" + } + }, + { + "name": "BIP39", + "entropy": "a9a70f43a52ea9f60ef7ccd57e37164e", + "words": 12, + "languages": { + "french": "ocean correct rival double theme village crucial veteran salon tunnel question minute" + } + }, + { + "name": "BIP39", + "entropy": "a2544a353acc0eac3c294c11775696a9cd1726b244f4211bad237b29e9e23d80", + "words": 24, + "languages": { + "english": "pelican pelican minute intact science figure vague civil badge rival pizza fatal sphere nation simple ozone canal talent emotion wagon ozone valve voyage angle" + } + }, + { + "name": "BIP39", + "entropy": "b536a67341dd7ecd3d599317da1eea31be1f45774d81287cdd477f9b03ecbfb8", + "words": 24, + "languages": { + "french": "pelican pelican minute intact science figure vague civil badge rival pizza fatal sphere nation simple ozone canal talent emotion wagon ozone valve voyage angle" + } + } ], "Electrum-V1": [ { @@ -352,4 +384,4 @@ } } ] -} \ No newline at end of file +} diff --git a/tests/hdwallet/mnemonics/test_mnemonics_bip39.py b/tests/hdwallet/mnemonics/test_mnemonics_bip39.py index e2b66ca1..6f1e80f3 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_bip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_bip39.py @@ -45,20 +45,29 @@ def test_bip39_mnemonics(data): for language in __["languages"].keys(): assert BIP39Mnemonic.is_valid_language(language=language) - # A BIP-39 Mnemonic must have a preferred language, to be deterministically decoded in all cases. - print( f"BIP39 {language} Mnemonic: {BIP39Mnemonic.normalize(__['languages'][language])}" ) + + # A BIP-39 Mnemonic must have a preferred language, to be deterministically decoded as + # is_valid in all cases. assert BIP39Mnemonic.is_valid(mnemonic=__["languages"][language], language=language) + # Create a random Mnemonic of the given strength in words, and the specified language, + # and ensure we can recover it mnemonic = BIP39Mnemonic.from_words(words=__["words"], language=language) assert len(mnemonic.split()) == __["words"] - assert BIP39Mnemonic(mnemonic=mnemonic).language().lower() == language + assert BIP39Mnemonic(mnemonic=mnemonic, language=language).language().lower() == language + + # Load the provided mnemonic. We assume NF[K]C encoding for Mnemonics we + # generate/normalize, so ensure that the reference mnemonic is in the same form. + # Recovering a Mnemonic from entropy or from a mnemonic phrase should yield the same + # canonicalized mnemonic; full BIP-39 words with UTF-8 Marks such as accents, regardless + # of whether the original Mnemonic had them or not. - # We assume NF[K]C encoding for Mnemonics we generate/normalize, so ensure that the - # reference mnemonic is in the same form - assert BIP39Mnemonic.from_entropy(entropy=__["entropy"], language=language) == unicodedata.normalize("NFKC", __["languages"][language]) - assert BIP39Mnemonic.decode(mnemonic=__["languages"][language]) == __["entropy"] + # If a Mnemonic is valid in multiple languages, a preferred language must be provided. + mnemonic = BIP39Mnemonic(mnemonic=__["languages"][language], language=language) + assert BIP39Mnemonic.from_entropy(entropy=__["entropy"], language=language) == mnemonic.mnemonic() - mnemonic = BIP39Mnemonic(mnemonic=__["languages"][language]) + # We can of course recover the entropy from the Mnemonic + assert BIP39Mnemonic.decode(mnemonic=__["languages"][language], language=language) == __["entropy"] assert mnemonic.name() == __["name"] assert mnemonic.language().lower() == language diff --git a/tests/test_bip39_cross_language.py b/tests/test_bip39_cross_language.py index d14478a5..81236d4c 100644 --- a/tests/test_bip39_cross_language.py +++ b/tests/test_bip39_cross_language.py @@ -8,8 +8,8 @@ import pytest from hdwallet.mnemonics.bip39 import BIP39Mnemonic -from hdwallet.mnemonics.imnemonic import Trie, TrieNode -from hdwallet.exceptions import ChecksumError +from hdwallet.mnemonics.imnemonic import Trie, TrieNode, WordIndices +from hdwallet.exceptions import ChecksumError, MnemonicError class TestBIP39CrossLanguage: """Test BIP39 mnemonics that work in both English and French languages. @@ -66,10 +66,11 @@ def setup_class(cls, languages: list[str] = None): # Load all specified languages language_data = {} - for language, words, indices in BIP39Mnemonic.language_words_indices(): + for language, words, indices in BIP39Mnemonic.wordlist_indices(): language_data[language] = dict( - words = words, indices = indices, + words = set( indices.keys() ), + unique = set( indices.unique() ), abbrevs = set( indices.abbreviations() ), ) if language not in languages: @@ -77,20 +78,22 @@ def setup_class(cls, languages: list[str] = None): # Set class attributes for backward compatibility setattr(cls, f"{language}_words", language_data[language]['words']) + setattr(cls, f"{language}_unique", language_data[language]['unique']) setattr(cls, f"{language}_indices", language_data[language]['indices']) setattr(cls, f"{language}_abbrevs", language_data[language]['abbrevs']) # Find common words across all languages - only process requested languages requested_data = {lang: language_data[lang] for lang in languages if lang in language_data} - all_word_sets = [set(data['words']) for data in requested_data.values()] + all_word_sets = [data['unique'] for data in requested_data.values()] all_abbrev_lists = [data['abbrevs'] for data in requested_data.values()] - cls.common_words = list(set.intersection(*all_word_sets)) if all_word_sets else [] - cls.common_abbrevs = list(set.intersection(*all_abbrev_lists)) if all_abbrev_lists else [] + cls.common_words = set.intersection(*all_word_sets) if all_word_sets else set() + cls.common_abbrevs = set.intersection(*all_abbrev_lists) if all_abbrev_lists else set() - # Print statistics + # Print statistics. Given that UTF-8 marks may or may not be supplied, there may be more + # unique words than the 2048 base UTF-8 BIP-39 words in the language. for lang, data in requested_data.items(): - print(f"{lang.capitalize()} words: {len(data['words'])}") + print(f"{lang.capitalize()} UTF-8 words base: {len(data['words'])} unique: {len(data['unique'])}, abbreviations: {len(data['abbrevs'])}") print(f"Common words found: {len(cls.common_words)}") print(f"First 20 common words: {sorted(cls.common_words)[:20]}") @@ -102,7 +105,7 @@ def create_random_mnemonic_from_common_words(self, word_count: int) -> str: if len(self.common_words) < word_count: raise ValueError(f"Not enough common words ({len(self.common_words)}) to create {word_count}-word mnemonic") - selected_words = random.choices(self.common_words, k=word_count) + selected_words = random.choices(list(self.common_words), k=word_count) return selected_words def test_common_words_exist(self): @@ -110,9 +113,11 @@ def test_common_words_exist(self): assert len(self.common_words) > 0, "No common words found between English and French wordlists" def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_attempts=1000): - """Test N-word mnemonics that work in both English and French.""" - successful_both_languages: List[List[str]] = [] + """Test N-word mnemonics that work in both English and French. + Actual rate for 2 languages will be expected_rate ^ 2.""" + successful_both_languages: List[List[str]] = [] + successful_english: int = 0 for _ in range(total_attempts): try: # Generate a random N-word mnemonic from common words @@ -132,39 +137,63 @@ def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_att #import traceback #print( f"Failed to decode: {traceback.format_exc()}" ) continue - + success_rate = len(successful_both_languages) / total_attempts - print(f"{words}-word mnemonics: {len(successful_both_languages)}/{total_attempts} successful ({success_rate:.4f})") - print(f"Expected rate: ~{expected_rate:.4f}") + print(f"{words}-word mnemonics: {len(successful_both_languages)}/{total_attempts} successful ({success_rate:.6f})") + print(f"Expected rate: ~{expected_rate:.6f} ^ 2 == {expected_rate ** 2:.6f}") + + # These rates are too small to test, in the small samples we're likely to use - # Assert we found at least some successful mnemonics - assert success_rate > 0, f"No {words}-word mnemonics worked in both languages" + # # Assert we found at least some successful mnemonics + # assert success_rate > 0, f"No {words}-word mnemonics worked in both languages" - # The success rate should be roughly around the expected rate, but due to - # randomness and limited common words, we'll accept a broader range - tolerance = 0.5 # 50% tolerance due to statistical variance - assert expected_rate * (1 - tolerance) < success_rate < expected_rate * (1 + tolerance), \ - f"Success rate {success_rate:.4f} not in expected range around {expected_rate:.4f}" + # # The success rate should be roughly around the expected rate, but due to + # # randomness and limited common words, we'll accept a broader range + # tolerance = 0.5 # 50% tolerance due to statistical variance + # assert expected_rate * (1 - tolerance) < success_rate < expected_rate * (1 + tolerance), \ + # f"Success rate {success_rate:.6f} not in expected range around {expected_rate:.4f}" return successful_both_languages def test_cross_language_12_word_mnemonics(self): - """Test 12-word mnemonics that work in both English and French.""" - candidates = self.dual_language_N_word_mnemonics(words=12, expected_rate=1/16, total_attempts=1000) + """Test 12-word mnemonics that work in both English and French. + + For example: + 'ocean correct rival double theme village crucial veteran salon tunnel question minute' + 'puzzle mobile video pelican bicycle ocean effort train junior brave effort theme' + 'elegant cruel science guide fortune nation humble lecture ozone dragon question village' + 'innocent prison romance jaguar voyage depart fruit crucial video salon reunion fatigue' + 'position dragon correct question figure notable service vague civil public distance emotion' + + """ + with pytest.raises(MnemonicError, match="Ambiguous languages french or english"): + BIP39Mnemonic.decode( + 'ocean correct rival double theme village crucial veteran salon tunnel question minute' + ) + candidates = self.dual_language_N_word_mnemonics(words=12, expected_rate=1/16) def test_cross_language_24_word_mnemonics(self): - """Test 24-word mnemonics that work in both English and French.""" - candidates = self.dual_language_N_word_mnemonics(words=24, expected_rate=1/256, total_attempts=5000) + """Test 24-word mnemonics that work in both English and French. + + For example: + 'pelican pelican minute intact science figure vague civil badge rival pizza fatal sphere nation simple ozone canal talent emotion wagon ozone valve voyage angle' + 'pizza intact noble fragile piece suspect legal badge vital guide coyote volume nature wagon badge festival danger train desert intact opinion veteran romance metal' + """ + with pytest.raises(MnemonicError, match="Ambiguous languages french or english"): + BIP39Mnemonic.decode( + 'pelican pelican minute intact science figure vague civil badge rival pizza fatal' + ' sphere nation simple ozone canal talent emotion wagon ozone valve voyage angle' + ) + + candidates = self.dual_language_N_word_mnemonics(words=24, expected_rate=1/256) def test_wordlist_properties(self): """Test basic properties of the wordlists.""" # Verify wordlist sizes - assert len(self.english_words) == 2048, f"English wordlist should have 2048 words, got {len(self.english_words)}" - assert len(self.french_words) == 2048, f"French wordlist should have 2048 words, got {len(self.french_words)}" - - # Verify no duplicates within each wordlist - assert len(set(self.english_words)) == len(self.english_words), "English wordlist contains duplicates" - assert len(set(self.french_words)) == len(self.french_words), "French wordlist contains duplicates" + assert len(self.english_words) == 2048, f"English base wordlist should have 2048 words, got {len(self.english_words)}" + assert len(self.english_unique) == 2048, f"English full unique wordlist should have 2048 words, got {len(self.english_unique)}" + assert len(self.french_words) == 2048, f"French base wordlist should have 2048 words, got {len(self.french_words)}" + assert len(self.french_unique) == 2774, f"French full unique wordlist should have 2774 words, got {len(self.french_unique)}" # Verify common words list properties assert len(self.common_words) > 0, "No common words found" @@ -398,6 +427,28 @@ class CustomTrieNode(TrieNode): print("✓ Design pattern allows for derived TrieNode classes with custom EMPTY values") + test_indices = WordIndices(test_words) + assert str(test_indices) == """\ +a b a n d o n == 0 + i l i t y == 1 + l e == 2 + o u t == 3 + v e == 4 + s e n t == 5 + o r b == 6 + t r a c t == 7 + u r d == 8 + u s e == 9 + d d == 10 + i c t == 11 + r e s s == 12 + j u s t == 13 + c c e s s == 14 + i d e n t == 15 + o u n t == 16 + u s e == 17 + h i e v e == 18""" + def test_ambiguous_languages(self): """Test that find_language correctly detects and raises errors for ambiguous mnemonics. @@ -412,7 +463,7 @@ def test_ambiguous_languages(self): pytest.skip(f"Not enough common words ({len(self.common_words)}) for ambiguity testing") # Test with 12-word mnemonics using only common words - test_mnemonic = self.common_words[:12] # Use first 12 common words + test_mnemonic = list(self.common_words)[:12] # Use first 12 common words # Test 1: find_language should detect ambiguity when no preferred language is specified try: @@ -445,7 +496,7 @@ def test_ambiguous_languages(self): # Test 3: Test with a different set of common words to ensure robustness if len(self.common_words) >= 24: # Try with different common words (offset by 6 to get different words) - alt_test_mnemonic = self.common_words[6:18] # Words 6-17 (12 words) + alt_test_mnemonic = list(self.common_words)[6:18] # Words 6-17 (12 words) try: word_indices, detected_language = BIP39Mnemonic.find_language(alt_test_mnemonic) @@ -484,5 +535,60 @@ def test_ambiguous_languages(self): print(f"✓ Tested with {len(test_mnemonic)} common words") print("✓ Verified ambiguity detection and preferred language resolution") + +def test_bip39_korean(): + # Confirm that UTF-8 Mark handling works in other languages (particularly Korean) + (_, korean_nfc, korean_indices), = BIP39Mnemonic.wordlist_indices( + dict( + korean = BIP39Mnemonic.wordlist_path["korean"] + ) + ) + korean_nfc_20 = "\n".join(korean_nfc[:20]) + #print( korean_nfc_20 ) + assert korean_nfc_20 == """\ +가격 +가끔 +가난 +가능 +가득 +가르침 +가뭄 +가방 +가상 +가슴 +가운데 +가을 +가이드 +가입 +가장 +가정 +가족 +가죽 +각오 +각자""" + korean_trie_20 = "\n".join(korean_indices._trie.dump_lines()[:20]) + print(korean_trie_20) + assert korean_trie_20 == """\ +가 격 == 0 + 끔 == 1 + 난 == 2 + 능 == 3 + 득 == 4 + 르 침 == 5 + 뭄 == 6 + 방 == 7 + 상 == 8 + 슴 == 9 + 운 데 == 10 + 을 == 11 + 이 드 == 12 + 입 == 13 + 장 == 14 + 정 == 15 + 족 == 16 + 죽 == 17 +각 오 == 18 + 자 == 19""" + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/test_unicode_normalization.py b/tests/test_unicode_normalization.py index b6d4d940..948c2344 100644 --- a/tests/test_unicode_normalization.py +++ b/tests/test_unicode_normalization.py @@ -8,6 +8,42 @@ import pytest +def remove_accents_safe(text: str) -> str: + """Remove accents from Latin/Cyrillic/Greek scripts, preserve other scripts. + + Not the correct approach; doesn't work for eg. Korean, where NFD expands unicodedata.category + "Lo" (Letter other) symbols to simply more "Lo" symbols. + + """ + text_nfd = unicodedata.normalize('NFD', text) + result = [] + for char in text_nfd: + category = unicodedata.category(char) + if category.startswith('M'): # Mark (combining) characters + if result and self._is_latin_cyrillic_greek_script(result[-1]): + continue # Skip accent marks on Latin/Cyrillic/Greek characters + result.append(char) + return ''.join(result) + +def remove_accents_safe(text: str) -> str: + """Remove accents from texts if the removed Marks leave the same number of resultant Letter glyphs. + + Normalizes all incoming text to NFC for consistency (may be raw NFD eg. from BIP-39 word lists) + + """ + text_nfc = unicodedata.normalize('NFC', text) + text_nfd = unicodedata.normalize('NFD', text_nfc) + result = [] + for char in text_nfd: + category = unicodedata.category(char) + if category.startswith('M'): # Mark (combining) characters + continue # Skip accent marks + result.append(char) + if len(result) == len(text_nfc): + return ''.join(result) + return text_nfc + + @dataclass class CharacterInfo: """Information about a single Unicode character.""" @@ -310,29 +346,6 @@ def test_accent_removal_for_fallback_matching(self): This could be useful for fuzzy matching when exact Unicode matches fail. """ - def remove_accents_safe(text: str) -> str: - """Remove accents from Latin/Cyrillic/Greek scripts, preserve other scripts.""" - - # First normalize to NFD to separate base characters from combining diacritics - nfd_text = unicodedata.normalize('NFD', text) - - result = [] - for char in nfd_text: - # Get the Unicode category and script information - category = unicodedata.category(char) - - # Skip combining characters (accents) for Latin, Cyrillic, Greek scripts - if category.startswith('M'): # Mark (combining) characters - # Only remove combining marks that are typically accent marks - # Check if the previous character was from a script we want to modify - if result and self._is_latin_cyrillic_greek_script(result[-1]): - # Skip this combining character (remove the accent) - continue - - # Keep the base character - result.append(char) - - return ''.join(result) print("\n=== Accent Removal Tests ===") @@ -489,18 +502,6 @@ def _is_latin_cyrillic_greek_script(self, char: str) -> bool: def test_accent_removal_with_bip39_words(self): """Test accent removal specifically with BIP-39 words from accented languages.""" - def remove_accents_safe(text: str) -> str: - """Remove accents from Latin/Cyrillic/Greek scripts, preserve other scripts.""" - nfd_text = unicodedata.normalize('NFD', text) - result = [] - for char in nfd_text: - category = unicodedata.category(char) - if category.startswith('M'): # Mark (combining) characters - if result and self._is_latin_cyrillic_greek_script(result[-1]): - continue # Skip accent marks on Latin/Cyrillic/Greek characters - result.append(char) - return ''.join(result) - print("\n=== BIP-39 Word Accent Removal Tests ===") # Test with actual French BIP-39 words (using our test words from setup_class) @@ -536,7 +537,8 @@ def remove_accents_safe(text: str) -> str: print(f" '{original}' -> '{deaccented}'") assert deaccented == expected, f"Expected '{expected}', got '{deaccented}'" - # Test that non-Latin scripts are unchanged + # Test that non-Latin scripts are unchanged, unless removing the Marks leaves the same + # number of Letter category glyphs/symbols. non_latin_examples = [ '中文', # Chinese 'あいう', # Japanese Hiragana @@ -1160,7 +1162,3 @@ def test_compatibility_normalization_security_implications(self): print(f"\nRECOMMENDATION: Carefully validate NFKC normalization in BIP-39 implementations") else: print(f"No obvious security concerns found with NFKC normalization") - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) From 949a0c4bcb95106eb63bb911b837bb2a3cfc8de1 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Fri, 26 Sep 2025 11:08:12 -0600 Subject: [PATCH 22/38] Progress toward passing all tests; restore custom words_list functionality --- Makefile | 4 +- hdwallet/mnemonics/bip39/mnemonic.py | 8 +++- hdwallet/mnemonics/imnemonic.py | 66 +++++++++++++++++++-------- hdwallet/mnemonics/slip39/mnemonic.py | 24 ++++++---- 4 files changed, 72 insertions(+), 30 deletions(-) diff --git a/Makefile b/Makefile index 21452587..9ea7d5e7 100644 --- a/Makefile +++ b/Makefile @@ -44,9 +44,9 @@ $(WHEEL): FORCE install: $(WHEEL) FORCE $(PYTHON) -m pip install --force-reinstall $<[cli,tests,docs] -# Install from requirements/*; eg. install-dev +# Install from requirements/*; eg. install-dev, always getting the latest version install-%: FORCE - $(PYTHON) -m pip install -r requirements/$*.txt + $(PYTHON) -m pip install --upgrade -r requirements/$*.txt unit-%: diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index 09adb0b7..d1183fb1 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -250,6 +250,7 @@ def decode( mnemonic: str, language: Optional[str] = None, checksum: bool = False, + words_list: Optional[List[str]] = None, words_list_with_index: Optional[Mapping[str, int]] = None, ) -> str: """ @@ -277,8 +278,13 @@ def decode( if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) + # May optionally provide a word<->index Mapping, or a language + words_list; if neither, the Mnemonic defaults are used. if not words_list_with_index: - words_list_with_index, language = cls.find_language(mnemonic=words, language=language) + wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None + if words_list: + assert language, f"Must provide language with words_list" + wordlist_path = { language: words_list } + words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) if len(set(words_list_with_index.values())) != cls.words_list_number: raise Error( "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 18b49a98..fb37cfc5 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -356,9 +356,12 @@ def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: """ mnemonic_list: List[str] = self.normalize(mnemonic) - # Attempt to unambiguously determine the Mnemonic's language using any preferred 'language' + + # Attempt to unambiguously determine the Mnemonic's language using any preferred 'language'. + # Raises a MnemonicError if the words are not valid. self._word_indices, self._language = self.find_language(mnemonic_list, language=kwargs.get("language")) self._mnemonic_type = kwargs.get("mnemonic_type", None) + # We now know with certainty that the list of Mnemonic words was valid in some language. # However, they may have been abbreviations, or had optional UTF-8 Marks removed. So, use # the _word_indices twice, to map from str (matching word) -> int (index) -> str (canonical @@ -367,6 +370,7 @@ def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: for w in mnemonic_list ] self._words = len(self._mnemonic) + # We have the canonical Mnemonic words. Decode them, preserving the real MnemonicError # details if the words do not form a valid Mnemonic. self.decode(self._mnemonic, **kwargs) @@ -434,7 +438,7 @@ def decode(cls, mnemonic: Union[str, List[str]], **kwargs) -> str: @classmethod def get_words_list_by_language( - cls, language: str, wordlist_path: Optional[Dict[str, str]] = None + cls, language: str, wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None ) -> List[str]: """Retrieves the standardized (NFC normalized, lower-cased) word list for the specified language. @@ -445,10 +449,20 @@ def get_words_list_by_language( We do not want to use 'normalize' to do this, because normalization of Mnemonics may have additional functionality beyond just ensuring symbol and case standardization. + Supports wordlist_path containing either a path: + + {'language': '/some/path'}, + + or the words_list content: + + {'language': ['words', 'list', ...]} + + Ignores blank lines and # ... comments + :param language: The language for which to get the word list. :type language: str - :param wordlist_path: Optional dictionary mapping language names to file paths of their word lists. - :type wordlist_path: Optional[Dict[str, str]] + :param wordlist_path: Optional dictionary mapping language names to file paths of their word lists, or the words list. + :type wordlist_path: Optional[Dict[str, Tuple[str, List[str]]] :return: A list of words for the specified language, normalized to NFC form. :rtype: List[str] @@ -456,22 +470,33 @@ def get_words_list_by_language( """ wordlist_path = cls.wordlist_path if wordlist_path is None else wordlist_path + + # May provide a filesystem path str, or a List-like sequence of words + if isinstance( wordlist_path[language], str ): + with open(os.path.join(os.path.dirname(__file__), wordlist_path[language]), "r", encoding="utf-8") as fin: + words_list_raw: List[str] = list( fin ) + else: + words_list_raw: List[str] = list( wordlist_path[language] ) + + # Ensure any words are provided in either NFKC or NFKD form. This eliminates words lists + # where the provided word is not in standard NFC or NFD form, down-cases them and removes + # any leading/trailing whitespace, then ignores empty lines or full-line comments (trailing + # comments are not supported). words_list: List[str] = [] - with open(os.path.join(os.path.dirname(__file__), wordlist_path[language]), "r", encoding="utf-8") as fin: - for word in map( str.lower, map( str.strip, fin )): - if not word or word.startswith("#"): - continue - word_nfc = unicodedata.normalize("NFKC", word) - word_nfkd = unicodedata.normalize( "NFKD", word_nfc) - assert word == word_nfkd or word == word_nfc, \ - f"Original {language} word {word!r} failed to round-trip through NFC: {word_nfc!r} / NFKD: {word_nfkd!r}" - words_list.append(word_nfc) + for word in map( str.lower, map( str.strip, words_list_raw )): + if not word or word.startswith("#"): + continue + word_nfc = unicodedata.normalize("NFKC", word) + word_nfkd = unicodedata.normalize( "NFKD", word_nfc) + assert word == word_nfkd or word == word_nfc, \ + f"Original {language} word {word!r} failed to round-trip through NFC: {word_nfc!r} / NFKD: {word_nfkd!r}" + words_list.append(word_nfc) return words_list @classmethod def wordlist_indices( - cls, wordlist_path: Optional[Dict[str, str]] = None + cls, wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, ) -> Tuple[str, List[str], WordIndices]: """Yields each 'candidate' language, its NFKC-normalized words List, and its WordIndices Mapping supporting indexing by 'int' word index, or 'str' with optional accents and all @@ -575,12 +600,13 @@ def find_language( except (MnemonicError, ValueError): continue - # No unambiguous match to any preferred language found. Select the best available. Sort by - # the number of characters matched (more is better - less ambiguous). This is a statistical - # method; it is still dangerous, and we should fail instead of returning a bad guess! + # No unambiguous match to any preferred language found (or no language matched all words). if not quality: - raise MnemonicError(f"Unrecognized language for mnemonic '{mnemonic}'") + raise MnemonicError(f"Invalid mnemonic words") + # Select the best available. Sort by the number of characters matched (more is better - + # less ambiguous). This is a statistical method; it is still dangerous, and we should fail + # instead of returning a bad guess! (candidate, matches), *worse = sorted(quality.items(), key=lambda k_v: k_v[1], reverse=True ) if worse and matches == worse[0][1]: # There are more than one matching candidate languages -- and they are both equivalent @@ -646,6 +672,10 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: decomposed characters and accents, and down-cases uppercase symbols using NFKC normalization. + This does not canonicalize the Mnemonic, because we do not know the language, nor can we + reliably deduce it without a preferred language (since Mnemonics may be valid in multiple + languages). + Recognizes hex strings (raw entropy), and attempts to normalize them as appropriate for the IMnemonic-derived class using 'from_entropy'. Thus, all IMnemonics can accept either mnemonic strings or raw hex-encoded entropy, if they use the IMnemonic.normalize base diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index e6392e3c..95e20187 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -534,19 +534,22 @@ def encode( @classmethod def decode( - cls, mnemonic: str, passphrase: str = "", + cls, mnemonic: str, passphrase: str = "", language: Optional[str] = None, ) -> str: - """ - Decodes a mnemonic phrase into its corresponding entropy. + """Decodes SLIP-39 mnemonic phrases into its corresponding entropy. This method converts a given mnemonic phrase back into its original entropy value. It - verifies several internal hashes to ensure the mnemonic and decoding is valid. However, the - passphrase has no verification; all derived entropies are considered equivalently valid (you - can use several passphrases to recover multiple, distinct sets of entropy.) So, it is - solely your responsibility to remember your correct passphrase(s). + verifies several internal hashes to ensure the mnemonic and decoding is valid. + + The passphrase has no verification; all derived entropies are considered equivalently valid + (you can use several passphrases to recover multiple, distinct sets of entropy.) So, it is + solely your responsibility to remember your correct passphrase(s): this is a design feature + of SLIP-39. The default "extendable" SLIP-39 :param mnemonic: The mnemonic phrase to decode. :type mnemonic: str + :param language: The preferred language of the mnemonic phrase + :type language: Optional[str] :param passphrase: The SLIP-39 passphrase (default: "") :type passphrase: str @@ -556,6 +559,9 @@ def decode( """ mnemonic_list: List[str] = cls.normalize(mnemonic) try: + if language and language not in cls.languages: + raise ValueError( f"Invalid SLIP-39 language: {language}" ) + mnemonic_words, = filter(lambda words: len(mnemonic_list) % words == 0, cls.words_list) mnemonic_chunks: Iterable[List[str]] = zip(*[iter(mnemonic_list)] * mnemonic_words) mnemonic_lines: Iterable[str] = map(" ".join, mnemonic_chunks) @@ -574,10 +580,10 @@ def decode( ) + " mnemonics required" ) - entropy = bytes_to_string(recovery.recover(passphrase.encode('UTF-8'))) - return entropy + entropy: str = bytes_to_string(recovery.recover(passphrase.encode('UTF-8'))) except Exception as exc: raise MnemonicError("Failed to recover SLIP-39 Mnemonics", detail=exc) from exc + return entropy NORMALIZE = re.compile( r""" From f72194ac98c2dcf14cd2acb250bdf6dbadfd42ba Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Tue, 30 Sep 2025 06:20:36 -0600 Subject: [PATCH 23/38] Progress toward preferred language for mnemonics o Re-order languages in rough probability for increased speed o Support wordlist_path with filename or mnemonic word list --- Makefile | 2 +- flake.nix | 5 +- hdwallet/cli/__main__.py | 3 + hdwallet/cli/generate/seed.py | 13 ++- hdwallet/mnemonics/algorand/mnemonic.py | 18 ++-- hdwallet/mnemonics/bip39/mnemonic.py | 59 ++++++------ hdwallet/mnemonics/electrum/v1/mnemonic.py | 60 +++++------- hdwallet/mnemonics/electrum/v2/mnemonic.py | 94 ++++++++++--------- hdwallet/mnemonics/imnemonic.py | 67 +++++++++---- hdwallet/mnemonics/monero/mnemonic.py | 51 +++++----- hdwallet/mnemonics/slip39/mnemonic.py | 6 +- hdwallet/seeds/algorand.py | 15 ++- hdwallet/seeds/bip39.py | 24 +++-- hdwallet/seeds/cardano.py | 61 +++++------- hdwallet/seeds/electrum/v1.py | 20 ++-- hdwallet/seeds/electrum/v2.py | 12 +-- hdwallet/seeds/iseed.py | 9 +- hdwallet/seeds/monero.py | 14 ++- hdwallet/seeds/slip39.py | 8 +- tests/cli/test_cli_mnemonic.py | 51 ++++++---- tests/cli/test_cli_seed.py | 15 ++- tests/data/json/mnemonics.json | 24 ++--- tests/data/json/seeds.json | 29 +++--- tests/data/raw/languages.txt | 28 +++--- .../mnemonics/test_mnemonics_electrum_v1.py | 6 +- .../mnemonics/test_mnemonics_electrum_v2.py | 15 ++- .../mnemonics/test_mnemonics_monero.py | 17 ++-- tests/hdwallet/seeds/test_seeds_bip39.py | 5 - tests/hdwallet/seeds/test_seeds_cardano.py | 7 -- .../hdwallet/seeds/test_seeds_electrum_v2.py | 29 +++--- 30 files changed, 408 insertions(+), 359 deletions(-) diff --git a/Makefile b/Makefile index 9ea7d5e7..a0b2161d 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ export PYTHON ?= $(shell python3 --version >/dev/null 2>&1 && echo python3 || e PYTHON_V = $(shell $(PYTHON) -c "import sys; print('-'.join((('venv' if sys.prefix != sys.base_prefix else next(iter(filter(None,sys.base_prefix.split('/'))))),sys.platform,sys.implementation.cache_tag)))" 2>/dev/null ) export PYTEST ?= $(PYTHON) -m pytest -export PYTEST_OPTS ?= # -vv --capture=no --mypy +export PYTEST_OPTS ?= -vv --capture=no # --mypy VERSION = $(shell $(PYTHON) -c "exec(open('hdwallet/info.py').read()); print(__version__[1:])" ) diff --git a/flake.nix b/flake.nix index b196853c..ec442452 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,7 @@ python311Env = mkPythonEnv pkgs.python311; python312Env = mkPythonEnv pkgs.python312; python313Env = mkPythonEnv pkgs.python313; + python314Env = mkPythonEnv pkgs.python314; in { # Single development shell with all Python versions @@ -42,16 +43,18 @@ python311Env python312Env python313Env + #python314Env ]; shellHook = '' echo "Welcome to the multi-Python development environment!" echo "Available Python interpreters:" echo " python (default): $(python --version 2>&1 || echo 'not available')" - #echo " python3.10: $(python3.10 --version 2>&1 || echo 'not available')" + #echo " python3.10: $(python3.10 --version 2>&1 || echo 'not available')" echo " python3.11: $(python3.11 --version 2>&1 || echo 'not available')" echo " python3.12: $(python3.12 --version 2>&1 || echo 'not available')" echo " python3.13: $(python3.13 --version 2>&1 || echo 'not available')" + #echo " python3.14: $(python3.14 --version 2>&1 || echo 'not available')" echo "" echo "All versions have pytest, coincurve, scikit-learn, pycryptodome, and pynacl installed." ''; diff --git a/hdwallet/cli/__main__.py b/hdwallet/cli/__main__.py index 3b2e679b..9760ef60 100644 --- a/hdwallet/cli/__main__.py +++ b/hdwallet/cli/__main__.py @@ -151,6 +151,9 @@ def cli_mnemonic(**kwargs) -> None: @click.option( "-m", "--mnemonic", multiple=True, help="Set Seed mnemonic(s)" ) +@click.option( + "-l", "--language", type=str, default=None, help="Set Mnemonic language", show_default=True +) @click.option( "-p", "--passphrase", type=str, default=None, help="Set Seed passphrase", show_default=True ) diff --git a/hdwallet/cli/generate/seed.py b/hdwallet/cli/generate/seed.py index f1b2eabc..2ccd5ce1 100644 --- a/hdwallet/cli/generate/seed.py +++ b/hdwallet/cli/generate/seed.py @@ -34,7 +34,7 @@ def generate_seed(**kwargs) -> None: sys.exit() elif kwargs.get("client") != SLIP39Seed.name(): # SLIP39 supports any 128-, 256- or 512-bit Mnemonic mnemonic_name: str = "BIP39" if kwargs.get("client") == CardanoSeed.name() else kwargs.get("client") - if not MNEMONICS.mnemonic(name=mnemonic_name).is_valid(mnemonic=kwargs.get("mnemonic")): + if not MNEMONICS.mnemonic(name=mnemonic_name).is_valid(mnemonic=kwargs.get("mnemonic"), language=kwargs.get("language")): click.echo(click.style(f"Invalid {mnemonic_name} mnemonic"), err=True) sys.exit() @@ -42,14 +42,16 @@ def generate_seed(**kwargs) -> None: seed: ISeed = BIP39Seed( seed=BIP39Seed.from_mnemonic( mnemonic=kwargs.get("mnemonic"), - passphrase=kwargs.get("passphrase") + passphrase=kwargs.get("passphrase"), + language=kwargs.get("language"), ) ) elif kwargs.get("client") == SLIP39Seed.name(): seed: ISeed = SLIP39Seed( seed=SLIP39Seed.from_mnemonic( mnemonic=kwargs.get("mnemonic"), - passphrase=kwargs.get("passphrase") + passphrase=kwargs.get("passphrase"), + language=kwargs.get("language"), ) ) elif kwargs.get("client") == CardanoSeed.name(): @@ -57,6 +59,7 @@ def generate_seed(**kwargs) -> None: seed=CardanoSeed.from_mnemonic( mnemonic=kwargs.get("mnemonic"), passphrase=kwargs.get("passphrase"), + language=kwargs.get("language"), cardano_type=kwargs.get("cardano_type") ) ) @@ -65,13 +68,15 @@ def generate_seed(**kwargs) -> None: seed=ElectrumV2Seed.from_mnemonic( mnemonic=kwargs.get("mnemonic"), passphrase=kwargs.get("passphrase"), + language=kwargs.get("language"), mnemonic_type=kwargs.get("mnemonic_type") ) ) else: seed: ISeed = SEEDS.seed(name=kwargs.get("client")).__call__( seed=SEEDS.seed(name=kwargs.get("client")).from_mnemonic( - mnemonic=kwargs.get("mnemonic") + mnemonic=kwargs.get("mnemonic"), + language=kwargs.get("language"), ) ) output: dict = { diff --git a/hdwallet/mnemonics/algorand/mnemonic.py b/hdwallet/mnemonics/algorand/mnemonic.py index f58a5f97..f1f2cefe 100644 --- a/hdwallet/mnemonics/algorand/mnemonic.py +++ b/hdwallet/mnemonics/algorand/mnemonic.py @@ -156,12 +156,17 @@ def encode(cls, entropy: Union[str, bytes], language: str) -> str: word_indexes: Optional[List[int]] = convert_bits(entropy, 8, 11) assert word_indexes is not None - words_list: list = cls.normalize(cls.get_words_list_by_language(language=language)) + words_list: list = cls.get_words_list_by_language(language=language) indexes: list = word_indexes + [checksum_word_indexes[0]] - return " ".join(cls.normalize([words_list[index] for index in indexes])) + return " ".join( words_list[index] for index in indexes ) @classmethod - def decode(cls, mnemonic: str, **kwargs) -> str: + def decode( + cls, + mnemonic: str, + language: Optional[str] = None, + **kwargs + ) -> str: """ Decodes a mnemonic phrase into entropy data. @@ -177,10 +182,7 @@ def decode(cls, mnemonic: str, **kwargs) -> str: if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - words_list, language = cls.find_language(mnemonic=words) - words_list_with_index: dict = { - words_list[i]: i for i in range(len(words_list)) - } + words_list_with_index, language = cls.find_language(mnemonic=words, language=language) word_indexes = [words_list_with_index[word] for word in words] entropy_list: Optional[List[int]] = convert_bits(word_indexes[:-1], 11, 8) assert entropy_list is not None @@ -191,7 +193,7 @@ def decode(cls, mnemonic: str, **kwargs) -> str: assert checksum_word_indexes is not None if checksum_word_indexes[0] != word_indexes[-1]: raise ChecksumError( - "Invalid checksum", expected=words_list[checksum_word_indexes[0]], got=words_list[word_indexes[-1]] + "Invalid checksum", expected=words_list_with_index.keys()[checksum_word_indexes[0]], got=words_list_with_index.keys()[word_indexes[-1]] ) return bytes_to_string(entropy) diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index d1183fb1..b06b02ff 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -77,30 +77,30 @@ class BIP39Mnemonic(IMnemonic): +-----------------------+----------------------+ | Name | Value | +=======================+======================+ - | CHINESE_SIMPLIFIED | chinese-simplified | - +-----------------------+----------------------+ - | CHINESE_TRADITIONAL | chinese-traditional | - +-----------------------+----------------------+ - | CZECH | czech | - +-----------------------+----------------------+ | ENGLISH | english | +-----------------------+----------------------+ | FRENCH | french | +-----------------------+----------------------+ - | ITALIAN | italian | + | SPANISH | spanish | +-----------------------+----------------------+ - | JAPANESE | japanese | + | ITALIAN | italian | +-----------------------+----------------------+ - | KOREAN | korean | + | RUSSIAN | russian | +-----------------------+----------------------+ | PORTUGUESE | portuguese | +-----------------------+----------------------+ - | RUSSIAN | russian | - +-----------------------+----------------------+ - | SPANISH | spanish | + | CZECH | czech | +-----------------------+----------------------+ | TURKISH | turkish | +-----------------------+----------------------+ + | KOREAN | korean | + +-----------------------+----------------------+ + | CHINESE_SIMPLIFIED | chinese-simplified | + +-----------------------+----------------------+ + | CHINESE_TRADITIONAL | chinese-traditional | + +-----------------------+----------------------+ + | JAPANESE | japanese | + +-----------------------+----------------------+ """ word_bit_length: int = 11 @@ -120,32 +120,32 @@ class BIP39Mnemonic(IMnemonic): BIP39_MNEMONIC_WORDS.TWENTY_FOUR: BIP39_ENTROPY_STRENGTHS.TWO_HUNDRED_FIFTY_SIX } languages: List[str] = [ - BIP39_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED, - BIP39_MNEMONIC_LANGUAGES.CHINESE_TRADITIONAL, - BIP39_MNEMONIC_LANGUAGES.CZECH, BIP39_MNEMONIC_LANGUAGES.ENGLISH, BIP39_MNEMONIC_LANGUAGES.FRENCH, + BIP39_MNEMONIC_LANGUAGES.SPANISH, BIP39_MNEMONIC_LANGUAGES.ITALIAN, - BIP39_MNEMONIC_LANGUAGES.JAPANESE, - BIP39_MNEMONIC_LANGUAGES.KOREAN, - BIP39_MNEMONIC_LANGUAGES.PORTUGUESE, BIP39_MNEMONIC_LANGUAGES.RUSSIAN, - BIP39_MNEMONIC_LANGUAGES.SPANISH, - BIP39_MNEMONIC_LANGUAGES.TURKISH + BIP39_MNEMONIC_LANGUAGES.PORTUGUESE, + BIP39_MNEMONIC_LANGUAGES.CZECH, + BIP39_MNEMONIC_LANGUAGES.TURKISH, + BIP39_MNEMONIC_LANGUAGES.KOREAN, + BIP39_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED, + BIP39_MNEMONIC_LANGUAGES.CHINESE_TRADITIONAL, + BIP39_MNEMONIC_LANGUAGES.JAPANESE, ] wordlist_path: Dict[str, str] = { - BIP39_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED: "bip39/wordlist/chinese_simplified.txt", - BIP39_MNEMONIC_LANGUAGES.CHINESE_TRADITIONAL: "bip39/wordlist/chinese_traditional.txt", - BIP39_MNEMONIC_LANGUAGES.CZECH: "bip39/wordlist/czech.txt", BIP39_MNEMONIC_LANGUAGES.ENGLISH: "bip39/wordlist/english.txt", BIP39_MNEMONIC_LANGUAGES.FRENCH: "bip39/wordlist/french.txt", + BIP39_MNEMONIC_LANGUAGES.SPANISH: "bip39/wordlist/spanish.txt", BIP39_MNEMONIC_LANGUAGES.ITALIAN: "bip39/wordlist/italian.txt", - BIP39_MNEMONIC_LANGUAGES.JAPANESE: "bip39/wordlist/japanese.txt", - BIP39_MNEMONIC_LANGUAGES.KOREAN: "bip39/wordlist/korean.txt", - BIP39_MNEMONIC_LANGUAGES.PORTUGUESE: "bip39/wordlist/portuguese.txt", BIP39_MNEMONIC_LANGUAGES.RUSSIAN: "bip39/wordlist/russian.txt", - BIP39_MNEMONIC_LANGUAGES.SPANISH: "bip39/wordlist/spanish.txt", - BIP39_MNEMONIC_LANGUAGES.TURKISH: "bip39/wordlist/turkish.txt" + BIP39_MNEMONIC_LANGUAGES.PORTUGUESE: "bip39/wordlist/portuguese.txt", + BIP39_MNEMONIC_LANGUAGES.CZECH: "bip39/wordlist/czech.txt", + BIP39_MNEMONIC_LANGUAGES.TURKISH: "bip39/wordlist/turkish.txt", + BIP39_MNEMONIC_LANGUAGES.KOREAN: "bip39/wordlist/korean.txt", + BIP39_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED: "bip39/wordlist/chinese_simplified.txt", + BIP39_MNEMONIC_LANGUAGES.CHINESE_TRADITIONAL: "bip39/wordlist/chinese_traditional.txt", + BIP39_MNEMONIC_LANGUAGES.JAPANESE: "bip39/wordlist/japanese.txt", } @classmethod @@ -282,7 +282,8 @@ def decode( if not words_list_with_index: wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None if words_list: - assert language, f"Must provide language with words_list" + if not language: + raise Error( f"Must provide language with words_list" ) wordlist_path = { language: words_list } words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) if len(set(words_list_with_index.values())) != cls.words_list_number: diff --git a/hdwallet/mnemonics/electrum/v1/mnemonic.py b/hdwallet/mnemonics/electrum/v1/mnemonic.py index 63f75b1c..4295444a 100644 --- a/hdwallet/mnemonics/electrum/v1/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v1/mnemonic.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Dict, List, Union, Optional + Dict, List, Mapping, Union, Optional ) from ....entropies import ( @@ -177,7 +177,11 @@ def encode(cls, entropy: Union[str, bytes], language: str) -> str: @classmethod def decode( - cls, mnemonic: str, words_list: Optional[List[str]] = None, words_list_with_index: Optional[dict] = None + cls, + mnemonic: str, + language: Optional[str] = None, + words_list: Optional[List[str]] = None, + words_list_with_index: Optional[Mapping[str, int]] = None ) -> str: """ Decodes a mnemonic phrase back into entropy data. @@ -202,53 +206,31 @@ def decode( if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - if not words_list or not words_list_with_index: - words_list, language = cls.find_language(mnemonic=words) - words_list_with_index: dict = { - words_list[i]: i for i in range(len(words_list)) - } + # May optionally provide a word<->index Mapping, or a language + words_list; if neither, the Mnemonic defaults are used. + if not words_list_with_index: + wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None + if words_list: + assert language, f"Must provide language with words_list" + wordlist_path = { language: words_list } + words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) + if len(set(words_list_with_index.values())) != cls.words_list_number: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) + ) entropy: bytes = b"" for index in range(len(words) // 3): word_1, word_2, word_3 = words[index * 3:(index * 3) + 3] word_1_index: int = words_list_with_index[word_1] - word_2_index: int = words_list_with_index[word_2] % len(words_list) - word_3_index: int = words_list_with_index[word_3] % len(words_list) + word_2_index: int = words_list_with_index[word_2] % cls.words_list_number + word_3_index: int = words_list_with_index[word_3] % cls.words_list_number chunk: int = ( word_1_index + - (len(words_list) * ((word_2_index - word_1_index) % len(words_list))) + - (len(words_list) * len(words_list) * ((word_3_index - word_2_index) % len(words_list))) + (cls.words_list_number * ((word_2_index - word_1_index) % cls.words_list_number)) + + (cls.words_list_number * cls.words_list_number * ((word_3_index - word_2_index) % cls.words_list_number)) ) entropy += integer_to_bytes(chunk, bytes_num=4, endianness="big") return bytes_to_string(entropy) - - @classmethod - def is_valid( - cls, mnemonic: Union[str, List[str]], words_list: Optional[List[str]] = None, words_list_with_index: Optional[dict] = None - ) -> bool: - """ - Checks if the given mnemonic phrase is valid. - - This method decodes the mnemonic phrase and verifies its validity using the specified word lists and index mappings. - - :param mnemonic: The mnemonic phrase to check, either as a space-separated string or a list of words. - :type mnemonic: Union[str, List[str]] - :param words_list: Optional list of valid words for the mnemonic phrase, normalized and in the correct order. - If not provided, uses `cls.get_words_list_by_language` to fetch the list based on the default language. - :type words_list: Optional[List[str]], optional - :param words_list_with_index: Optional dictionary mapping words to their indices for quick lookup. - If not provided, constructs this mapping based on `words_list`. - :type words_list_with_index: Optional[dict], optional - - :return: True if the mnemonic phrase is valid, False otherwise. - :rtype: bool - """ - - try: - cls.decode(mnemonic=mnemonic, words_list=words_list, words_list_with_index=words_list_with_index) - return True - except (ValueError, KeyError, MnemonicError): - return False diff --git a/hdwallet/mnemonics/electrum/v2/mnemonic.py b/hdwallet/mnemonics/electrum/v2/mnemonic.py index c7e360ea..56d8811e 100644 --- a/hdwallet/mnemonics/electrum/v2/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v2/mnemonic.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Dict, List, Union, Optional + Dict, List, Mapping, Union, Optional ) from ....entropies import ( @@ -75,13 +75,13 @@ class ElectrumV2Mnemonic(IMnemonic): +-----------------------+----------------------+ | Name | Value | +=======================+======================+ - | CHINESE_SIMPLIFIED | chinese-simplified | - +-----------------------+----------------------+ | ENGLISH | english | +-----------------------+----------------------+ + | SPANISH | spanish | + +-----------------------+----------------------+ | PORTUGUESE | portuguese | +-----------------------+----------------------+ - | SPANISH | spanish | + | CHINESE_SIMPLIFIED | chinese-simplified | +-----------------------+----------------------+ Here are available ``ELECTRUM_V2_MNEMONIC_TYPES``: @@ -100,6 +100,7 @@ class ElectrumV2Mnemonic(IMnemonic): """ word_bit_length: int = 11 + words_list_number: int = 2048 words_list: List[int] = [ ELECTRUM_V2_MNEMONIC_WORDS.TWELVE, ELECTRUM_V2_MNEMONIC_WORDS.TWENTY_FOUR @@ -109,16 +110,16 @@ class ElectrumV2Mnemonic(IMnemonic): ELECTRUM_V2_MNEMONIC_WORDS.TWENTY_FOUR: ELECTRUM_V2_ENTROPY_STRENGTHS.TWO_HUNDRED_SIXTY_FOUR } languages: List[str] = [ - ELECTRUM_V2_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED, ELECTRUM_V2_MNEMONIC_LANGUAGES.ENGLISH, + ELECTRUM_V2_MNEMONIC_LANGUAGES.SPANISH, ELECTRUM_V2_MNEMONIC_LANGUAGES.PORTUGUESE, - ELECTRUM_V2_MNEMONIC_LANGUAGES.SPANISH + ELECTRUM_V2_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED, ] wordlist_path: Dict[str, str] = { - ELECTRUM_V2_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED: "electrum/v2/wordlist/chinese_simplified.txt", ELECTRUM_V2_MNEMONIC_LANGUAGES.ENGLISH: "electrum/v2/wordlist/english.txt", + ELECTRUM_V2_MNEMONIC_LANGUAGES.SPANISH: "electrum/v2/wordlist/spanish.txt", ELECTRUM_V2_MNEMONIC_LANGUAGES.PORTUGUESE: "electrum/v2/wordlist/portuguese.txt", - ELECTRUM_V2_MNEMONIC_LANGUAGES.SPANISH: "electrum/v2/wordlist/spanish.txt" + ELECTRUM_V2_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED: "electrum/v2/wordlist/chinese_simplified.txt", } mnemonic_types: Dict[str, str] = { ELECTRUM_V2_MNEMONIC_TYPES.STANDARD: "01", @@ -218,25 +219,18 @@ def from_entropy( if ElectrumV2Entropy.are_entropy_bits_enough(entropy): - words_list: List[str] = cls.normalize(cls.get_words_list_by_language( + words_list: List[str] = cls.get_words_list_by_language( language=language, wordlist_path=cls.wordlist_path - )) - bip39_words_list: List[str] = cls.normalize(cls.get_words_list_by_language( + ) + bip39_words_list: List[str] = cls.get_words_list_by_language( language=language, wordlist_path=BIP39Mnemonic.wordlist_path - )) - bip39_words_list_with_index: dict = { - bip39_words_list[i]: i for i in range(len(bip39_words_list)) - } + ) try: - electrum_v1_words_list: List[str] = cls.normalize(cls.get_words_list_by_language( + electrum_v1_words_list: List[str] = cls.get_words_list_by_language( language=language, wordlist_path=ElectrumV1Mnemonic.wordlist_path - )) - electrum_v1_words_list_with_index: dict = { - electrum_v1_words_list[i]: i for i in range(len(electrum_v1_words_list)) - } + ) except KeyError: - electrum_v1_words_list: List[str] = [ ] - electrum_v1_words_list_with_index: dict = { } + electrum_v1_words_list: Optional[List[str]] = None entropy: int = bytes_to_integer(entropy) for index in range(max_attempts): @@ -248,9 +242,9 @@ def from_entropy( mnemonic_type=mnemonic_type, words_list=words_list, bip39_words_list=bip39_words_list, - bip39_words_list_with_index=bip39_words_list_with_index, + bip39_words_list_with_index=None, electrum_v1_words_list=electrum_v1_words_list, - electrum_v1_words_list_with_index=electrum_v1_words_list_with_index + electrum_v1_words_list_with_index=None, ) except EntropyError: continue @@ -265,9 +259,9 @@ def encode( mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.STANDARD, words_list: Optional[List[str]] = None, bip39_words_list: Optional[List[str]] = None, - bip39_words_list_with_index: Optional[dict] = None, + bip39_words_list_with_index: Optional[Mapping[str, int]] = None, electrum_v1_words_list: Optional[List[str]] = None, - electrum_v1_words_list_with_index: Optional[dict] = None + electrum_v1_words_list_with_index: Optional[Mapping[str, int]] = None ) -> str: """ Generates a mnemonic phrase from entropy data. @@ -302,14 +296,21 @@ def encode( mnemonic: List[str] = [] if not words_list: - words_list = cls.normalize(cls.get_words_list_by_language(language=language)) - while entropy > 0: - word_index: int = entropy % len(words_list) - entropy //= len(words_list) + words_list = cls.get_words_list_by_language(language=language) + if len(words_list) != cls.words_list_number: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) + ) + + # Produces mnemonics of valid length, even if entropy has trailing zero value + while entropy > 0 or len(mnemonic) not in set(self.words_list): + word_index: int = entropy % cls.words_list_numbrer + entropy //= cls.words_list_number mnemonic.append(words_list[word_index]) if not cls.is_valid( mnemonic=mnemonic, + language=language, mnemonic_type=mnemonic_type, bip39_words_list=bip39_words_list, bip39_words_list_with_index=bip39_words_list_with_index, @@ -318,10 +319,15 @@ def encode( ): raise EntropyError("Entropy bytes are not suitable for generating a valid mnemonic") - return " ".join(cls.normalize(mnemonic)) + return " ".join(mnemonic) # mnemonic words already NFKC normalized @classmethod - def decode(cls, mnemonic: str, mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.STANDARD) -> str: + def decode( + cls, + mnemonic: str, + language: Optional[str] = None, + mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.STANDARD + ) -> str: """ Decodes a mnemonic phrase into its original entropy value. @@ -341,17 +347,18 @@ def decode(cls, mnemonic: str, mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.S if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - if not cls.is_valid(mnemonic, mnemonic_type=mnemonic_type): - raise MnemonicError(f"Invalid {mnemonic_type} mnemonic type words") + # if not cls.is_valid(mnemonic, language=language, mnemonic_type=mnemonic_type): + # raise MnemonicError(f"Invalid {mnemonic_type} mnemonic type words") - words_list, language = cls.find_language(mnemonic=words) - words_list_with_index: dict = { - words_list[i]: i for i in range(len(words_list)) - } + words_list_with_index, language = cls.find_language(mnemonic=words, language=language) + if len(words_list_with_index) != cls.words_list_number: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list_with_index) + ) entropy: int = 0 for word in reversed(words): - entropy: int = (entropy * len(words_list)) + words_list_with_index[word] + entropy: int = (entropy * len(words_list_with_index)) + words_list_with_index[word] return bytes_to_string(integer_to_bytes(entropy)) @@ -359,11 +366,12 @@ def decode(cls, mnemonic: str, mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.S def is_valid( cls, mnemonic: Union[str, List[str]], + language: Optional[str] = None, mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.STANDARD, bip39_words_list: Optional[List[str]] = None, - bip39_words_list_with_index: Optional[dict] = None, + bip39_words_list_with_index: Optional[Mapping[str, int]] = None, electrum_v1_words_list: Optional[List[str]] = None, - electrum_v1_words_list_with_index: Optional[dict] = None + electrum_v1_words_list_with_index: Optional[Mapping[str, int]] = None ) -> bool: """ Checks if the given mnemonic is valid according to the specified mnemonic type. @@ -389,9 +397,9 @@ def is_valid( """ if BIP39Mnemonic.is_valid( - mnemonic, words_list=bip39_words_list, words_list_with_index=bip39_words_list_with_index + mnemonic, language=language, words_list=bip39_words_list, words_list_with_index=bip39_words_list_with_index ) or ElectrumV1Mnemonic.is_valid( - mnemonic, words_list=electrum_v1_words_list, words_list_with_index=electrum_v1_words_list_with_index + mnemonic, language=language, words_list=electrum_v1_words_list, words_list_with_index=electrum_v1_words_list_with_index ): return False return cls.is_type( diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index fb37cfc5..f8858b33 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -20,7 +20,7 @@ from collections import defaultdict -from ..exceptions import MnemonicError +from ..exceptions import MnemonicError, ChecksumError from ..entropies import IEntropy @@ -221,8 +221,24 @@ def unmark( word_composed: str ) -> str: class WordIndices( abc.Mapping ): """A Mapping which holds a Sequence of Mnemonic words. - Indexable either by int (returning the original word), or by the original word (with or without - Unicode "Marks") or a unique abbreviations, returning the int index. + Acts like a basic { "word": index, ... } dict but with additional word flexibility. + + Also behaves like a ["word", "word", ...] list for iteration and indexing. + + Indexable either by int (returning the original canonical word), or by the original word (with + or without Unicode "Marks") or a unique abbreviations, returning the int index. + + The base mapping is str -> int, and keys()/iter() returns the canonical Mnemonic words. + + The index value for a certain mnemonic word (with our without "Marks") or an abbreviation + thereof can be obtained: + + [str(word)] + + The canonical mnemonic word in "NFC" form at a certain index can be obtained via: + + [int(index)] + .keys()[int(index)] """ def __init__(self, sequence): @@ -364,10 +380,11 @@ def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: # We now know with certainty that the list of Mnemonic words was valid in some language. # However, they may have been abbreviations, or had optional UTF-8 Marks removed. So, use - # the _word_indices twice, to map from str (matching word) -> int (index) -> str (canonical + # the _word_indices mapping twice, from str (matching word/abbrev) -> int (index) -> str + # (canonical word) self._mnemonic: List[str] = [ - self._word_indices[self._word_indices[w]] - for w in mnemonic_list + self._word_indices[self._word_indices[word]] + for word in mnemonic_list ] self._words = len(self._mnemonic) @@ -443,17 +460,17 @@ def get_words_list_by_language( """Retrieves the standardized (NFC normalized, lower-cased) word list for the specified language. Uses NFC normalization for internal processing consistency. BIP-39 wordlists are generally - stored in NFD format (with some exceptions like russian) but we normalize to NFC for + stored in NFD format (with some exceptions like russian) but we normalize to NFC (for internal word comparisons and lookups, and for display. We do not want to use 'normalize' to do this, because normalization of Mnemonics may have additional functionality beyond just ensuring symbol and case standardization. - Supports wordlist_path containing either a path: + Supports wordlist_path mapping language to either a path: {'language': '/some/path'}, - or the words_list content: + or to the language's actual words_list data: {'language': ['words', 'list', ...]} @@ -515,7 +532,7 @@ def wordlist_indices( def find_language( cls, mnemonic: List[str], - wordlist_path: Optional[Dict[str, str]] = None, + wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, language: Optional[str] = None, ) -> Tuple[Mapping[str, int], str]: """Finds the language of the given mnemonic by checking against available word list(s), @@ -559,8 +576,8 @@ def find_language( :param mnemonic: The mnemonic to check, represented as a list of words. :type mnemonic: List[str] - :param wordlist_path: Optional dictionary mapping language names to file paths of their word lists. - :type wordlist_path: Optional[Dict[str, str]] + :param wordlist_path: Optional dictionary mapping language names to file paths of their word lists, or the word list. + :type wordlist_path: Optional[Dict[str, Union[str, List[str]]]] :param language: The preferred language, used if valid and mnemonic matches. :type mnemonic: Optional[str] @@ -584,7 +601,7 @@ def find_language( if candidate in quality: quality.pop(candidate) raise MnemonicError(f"Unable to find word {word} in {candidate}") from ex - word_canonical = words_indices[index] + word_canonical = words_indices.keys()[index] quality[candidate] += len( word_canonical ) if candidate == language: @@ -597,12 +614,16 @@ def find_language( # found to be unique abbreviations of words in the candidate, but it isn't the # preferred language (or no preferred language was specified). Keep track of its # quality of match, but carry on testing other candidate languages. - except (MnemonicError, ValueError): + except (MnemonicError, ValueError) as exc: + print( + f"Unrecognized mnemonic: {exc}" + # f" w/ indices:\n{words_indices}" + ) continue # No unambiguous match to any preferred language found (or no language matched all words). if not quality: - raise MnemonicError(f"Invalid mnemonic words") + raise MnemonicError(f"Invalid {cls.name()} mnemonic words") # Select the best available. Sort by the number of characters matched (more is better - # less ambiguous). This is a statistical method; it is still dangerous, and we should fail @@ -615,11 +636,12 @@ def find_language( return language_indices[candidate], candidate - @classmethod def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = None, **kwargs) -> bool: - """ - Checks if the given mnemonic is valid. + """Checks if the given mnemonic is valid. + + Catches mnemonic-validity related Exceptions and returns False, but lets others through; + asserts, hdwallet.exceptions.Error, general programming errors, etc. :param mnemonic: The mnemonic to check. :type mnemonic: str @@ -629,12 +651,17 @@ def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = Non :return: True if the strength is valid, False otherwise. :rtype: bool + """ try: cls.decode(mnemonic=mnemonic, language=language, **kwargs) return True - except (ValueError, MnemonicError): + except (ValueError, MnemonicError, ChecksumError) as exc: + print( + f"Invalid mnemonic: {exc}" + # f" w/ indices:\n{words_indices}" + ) return False @classmethod @@ -695,6 +722,6 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: if isinstance(mnemonic, str): if ( len(mnemonic.strip()) * 4 in cls.words_to_entropy_strength.values() and all(c in string.hexdigits for c in mnemonic.strip())): - mnemonic: str = cls.from_entropy(mnemonic, language="english") + mnemonic: str = cls.from_entropy(mnemonic, language=cls.languages[0]) mnemonic: List[str] = mnemonic.strip().split() return list(unicodedata.normalize("NFKC", word.lower()) for word in mnemonic) diff --git a/hdwallet/mnemonics/monero/mnemonic.py b/hdwallet/mnemonics/monero/mnemonic.py index 7046ca24..0c5c02b7 100644 --- a/hdwallet/mnemonics/monero/mnemonic.py +++ b/hdwallet/mnemonics/monero/mnemonic.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Union, Dict, List + Union, Dict, List, Optional ) from ...entropies import ( @@ -114,40 +114,40 @@ class MoneroMnemonic(IMnemonic): MONERO_MNEMONIC_WORDS.TWENTY_FIVE: MONERO_ENTROPY_STRENGTHS.TWO_HUNDRED_FIFTY_SIX } languages: List[str] = [ - MONERO_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED, - MONERO_MNEMONIC_LANGUAGES.DUTCH, MONERO_MNEMONIC_LANGUAGES.ENGLISH, MONERO_MNEMONIC_LANGUAGES.FRENCH, + MONERO_MNEMONIC_LANGUAGES.SPANISH, MONERO_MNEMONIC_LANGUAGES.GERMAN, + MONERO_MNEMONIC_LANGUAGES.DUTCH, MONERO_MNEMONIC_LANGUAGES.ITALIAN, - MONERO_MNEMONIC_LANGUAGES.JAPANESE, - MONERO_MNEMONIC_LANGUAGES.PORTUGUESE, MONERO_MNEMONIC_LANGUAGES.RUSSIAN, - MONERO_MNEMONIC_LANGUAGES.SPANISH + MONERO_MNEMONIC_LANGUAGES.PORTUGUESE, + MONERO_MNEMONIC_LANGUAGES.JAPANESE, + MONERO_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED, ] language_unique_prefix_lengths: Dict[str, int] = { - MONERO_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED: 1, - MONERO_MNEMONIC_LANGUAGES.DUTCH: 4, MONERO_MNEMONIC_LANGUAGES.ENGLISH: 3, MONERO_MNEMONIC_LANGUAGES.FRENCH: 4, + MONERO_MNEMONIC_LANGUAGES.SPANISH: 4, MONERO_MNEMONIC_LANGUAGES.GERMAN: 4, + MONERO_MNEMONIC_LANGUAGES.DUTCH: 4, MONERO_MNEMONIC_LANGUAGES.ITALIAN: 4, - MONERO_MNEMONIC_LANGUAGES.JAPANESE: 4, + MONERO_MNEMONIC_LANGUAGES.RUSSIAN: 4, MONERO_MNEMONIC_LANGUAGES.PORTUGUESE: 4, - MONERO_MNEMONIC_LANGUAGES.SPANISH: 4, - MONERO_MNEMONIC_LANGUAGES.RUSSIAN: 4 + MONERO_MNEMONIC_LANGUAGES.JAPANESE: 4, + MONERO_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED: 1, } wordlist_path: Dict[str, str] = { - MONERO_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED: "monero/wordlist/chinese_simplified.txt", - MONERO_MNEMONIC_LANGUAGES.DUTCH: "monero/wordlist/dutch.txt", MONERO_MNEMONIC_LANGUAGES.ENGLISH: "monero/wordlist/english.txt", MONERO_MNEMONIC_LANGUAGES.FRENCH: "monero/wordlist/french.txt", + MONERO_MNEMONIC_LANGUAGES.SPANISH: "monero/wordlist/spanish.txt", MONERO_MNEMONIC_LANGUAGES.GERMAN: "monero/wordlist/german.txt", + MONERO_MNEMONIC_LANGUAGES.DUTCH: "monero/wordlist/dutch.txt", MONERO_MNEMONIC_LANGUAGES.ITALIAN: "monero/wordlist/italian.txt", - MONERO_MNEMONIC_LANGUAGES.JAPANESE: "monero/wordlist/japanese.txt", - MONERO_MNEMONIC_LANGUAGES.PORTUGUESE: "monero/wordlist/portuguese.txt", MONERO_MNEMONIC_LANGUAGES.RUSSIAN: "monero/wordlist/russian.txt", - MONERO_MNEMONIC_LANGUAGES.SPANISH: "monero/wordlist/spanish.txt" + MONERO_MNEMONIC_LANGUAGES.PORTUGUESE: "monero/wordlist/portuguese.txt", + MONERO_MNEMONIC_LANGUAGES.JAPANESE: "monero/wordlist/japanese.txt", + MONERO_MNEMONIC_LANGUAGES.CHINESE_SIMPLIFIED: "monero/wordlist/chinese_simplified.txt", } @classmethod @@ -257,13 +257,20 @@ def encode(cls, entropy: Union[str, bytes], language: str, checksum: bool = Fals return " ".join(cls.normalize(mnemonic)) @classmethod - def decode(cls, mnemonic: str, **kwargs) -> str: + def decode( + cls, + mnemonic: str, + language: Optional[str] = None, + **kwargs + ) -> str: """ Decodes a mnemonic phrase into entropy data. :param mnemonic: The mnemonic phrase to decode. :type mnemonic: str - :param kwargs: Additional keyword arguments (language, checksum). + :param language: The preferred mnemonic language. + :type language: str + :param kwargs: Additional keyword arguments (checksum). :return: The decoded entropy data. :rtype: str @@ -273,10 +280,10 @@ def decode(cls, mnemonic: str, **kwargs) -> str: if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - words_list, language = cls.find_language(mnemonic=words) - if len(words_list) != cls.words_list_number: + words_list_with_index, language = cls.find_language(mnemonic=words, language=language) + if len(words_list_with_index) != cls.words_list_number: raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list_with_index) ) if len(words) in cls.words_checksum: @@ -295,6 +302,6 @@ def decode(cls, mnemonic: str, **kwargs) -> str: for index in range(len(words) // 3): word_1, word_2, word_3 = words[index * 3:(index * 3) + 3] entropy += words_to_bytes_chunk( - word_1, word_2, word_3, words_list, "little" + word_1, word_2, word_3, words_list_with_index.keys(), "little" ) return bytes_to_string(entropy) diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index 95e20187..292f7989 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -658,9 +658,9 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]: mnemonic_list: List[str] = mnemonic # Regardless of the Mnemonic source; the total number of words must be a valid multiple of - # the SLIP-39 mnemonic word lengths. Fortunately, the LCM of (20, 33 and 59) is 38940, so - # we cannot encounter a sufficient body of mnemonics to ever run into an uncertain SLIP-39 - # Mnemonic length in words. + # the SLIP-39 mnemonic word lengths. Fortunately, the LCM(20, 33) is 660, and LCM(20, 33 + # and 59) is 38940, so we are unlikely to encounter a sufficient body of mnemonics to ever + # run into an uncertain SLIP-39 Mnemonic length in words. word_lengths = list(filter(lambda w: len(mnemonic_list) % w == 0, cls.words_list)) if not word_lengths: errors.append( "Mnemonics not a multiple of valid length, or a single hex entropy value" ) diff --git a/hdwallet/seeds/algorand.py b/hdwallet/seeds/algorand.py index d6eed827..b39c6ba1 100644 --- a/hdwallet/seeds/algorand.py +++ b/hdwallet/seeds/algorand.py @@ -4,7 +4,7 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or https://opensource.org/license/mit -from typing import Union +from typing import Optional, Union from ..exceptions import MnemonicError from ..mnemonics import ( @@ -38,7 +38,7 @@ def name(cls) -> str: return "Algorand" @classmethod - def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], **kwargs) -> str: + def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], language: Optional[str] = None, **kwargs) -> str: """ Converts a mnemonic phrase to its corresponding seed. @@ -48,11 +48,8 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], **kwargs) -> str: :return: The decoded entropy as a string. :rtype: str """ + if not isinstance(mnemonic, IMnemonic): + mnemonic = AlgorandMnemonic(mnemonic=mnemonic, language=language) + assert isinstance(mnemonic, AlgorandMnemonic) - mnemonic = ( - mnemonic.mnemonic() if isinstance(mnemonic, IMnemonic) else mnemonic - ) - if not AlgorandMnemonic.is_valid(mnemonic=mnemonic): - raise MnemonicError(f"Invalid {cls.name()} mnemonic words") - - return AlgorandMnemonic.decode(mnemonic=mnemonic) + return AlgorandMnemonic.decode(mnemonic=mnemonic.mnemonic(), language=mnemonic.language()) diff --git a/hdwallet/seeds/bip39.py b/hdwallet/seeds/bip39.py index de0de911..28885cf9 100644 --- a/hdwallet/seeds/bip39.py +++ b/hdwallet/seeds/bip39.py @@ -52,9 +52,15 @@ def name(cls) -> str: return "BIP39" @classmethod - def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None) -> str: - """ - Converts a mnemonic phrase to its corresponding seed. + def from_mnemonic( + cls, + mnemonic: Union[str, IMnemonic], + passphrase: Optional[str] = None, + language: Optional[str] = None + ) -> str: + """Converts a canonical mnemonic phrase to its corresponding seed. Since a mnemonic string + may contain abbreviations, we canonicalize it by round-tripping it through the appropriate + IMnemonic type; this raises a MnemonicError exception for invalid mnemonics or languages. BIP39 stretches a prefix + (passphrase or "") + normalized mnemonic to produce the 512-bit seed. @@ -66,16 +72,14 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str :return: The decoded seed as a string. :rtype: str - """ - mnemonic = ( - mnemonic.mnemonic() if isinstance(mnemonic, IMnemonic) else mnemonic - ) - if not BIP39Mnemonic.is_valid(mnemonic=mnemonic): - raise MnemonicError(f"Invalid {cls.name()} mnemonic words") + """ + if not isinstance(mnemonic, IMnemonic): + mnemonic = BIP39Mnemonic(mnemonic=mnemonic, language=language) + assert isinstance(mnemonic, IMnemonic) # Normalize mnemonic to NFKD for seed generation as required by BIP-39 specification - normalized_mnemonic: str = unicodedata.normalize("NFKD", mnemonic) + normalized_mnemonic: str = unicodedata.normalize("NFKD", mnemonic.mnemonic()) # Salt normalization should use NFKD as per BIP-39 specification salt: str = unicodedata.normalize("NFKD", ( diff --git a/hdwallet/seeds/cardano.py b/hdwallet/seeds/cardano.py index 8e877b82..b2a19884 100644 --- a/hdwallet/seeds/cardano.py +++ b/hdwallet/seeds/cardano.py @@ -128,6 +128,7 @@ def from_mnemonic( cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None, + language: Optional[str] = None, cardano_type: str = Cardano.TYPES.BYRON_ICARUS ) -> str: """ @@ -150,25 +151,25 @@ def from_mnemonic( """ if cardano_type == Cardano.TYPES.BYRON_ICARUS: - return cls.generate_byron_icarus(mnemonic=mnemonic) + return cls.generate_byron_icarus(mnemonic=mnemonic, language=language) if cardano_type == Cardano.TYPES.BYRON_LEDGER: return cls.generate_byron_ledger( - mnemonic=mnemonic, passphrase=passphrase + mnemonic=mnemonic, passphrase=passphrase, language=language, ) if cardano_type == Cardano.TYPES.BYRON_LEGACY: - return cls.generate_byron_legacy(mnemonic=mnemonic) + return cls.generate_byron_legacy(mnemonic=mnemonic, language=language) if cardano_type == Cardano.TYPES.SHELLEY_ICARUS: - return cls.generate_shelley_icarus(mnemonic=mnemonic) + return cls.generate_shelley_icarus(mnemonic=mnemonic, language=language) elif cardano_type == Cardano.TYPES.SHELLEY_LEDGER: return cls.generate_shelley_ledger( - mnemonic=mnemonic, passphrase=passphrase + mnemonic=mnemonic, passphrase=passphrase, language=language ) raise Error( "Invalid Cardano type", expected=Cardano.TYPES.get_cardano_types(), got=cardano_type ) @classmethod - def generate_byron_icarus(cls, mnemonic: Union[str, IMnemonic]) -> str: + def generate_byron_icarus(cls, mnemonic: Union[str, IMnemonic], language: Optional[str] = None) -> str: """ Generates a Byron Icarus seed from a given mnemonic phrase. @@ -178,19 +179,14 @@ def generate_byron_icarus(cls, mnemonic: Union[str, IMnemonic]) -> str: :return: The derived Byron Icarus seed as a string. :rtype: str """ + if not isinstance(mnemonic, IMnemonic): + mnemonic = BIP39Mnemonic(mnemonic=mnemonic, language=language) + assert isinstance(mnemonic, BIP39Mnemonic) - mnemonic = ( - mnemonic.mnemonic() - if isinstance(mnemonic, IMnemonic) else - mnemonic - ) - if not BIP39Mnemonic.is_valid(mnemonic=mnemonic): - raise MnemonicError(f"Invalid {BIP39Mnemonic.name()} mnemonic words") - - return BIP39Mnemonic.decode(mnemonic=mnemonic) + return BIP39Mnemonic.decode(mnemonic=mnemonic.mnemonic(), language=mnemonic.language()) @classmethod - def generate_byron_ledger(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None) -> str: + def generate_byron_ledger(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None, language: Optional[str] = None) -> str: """ Generates a Byron Ledger seed from a given mnemonic phrase and optional passphrase. @@ -203,16 +199,14 @@ def generate_byron_ledger(cls, mnemonic: Union[str, IMnemonic], passphrase: Opti :return: The derived Byron Ledger seed as a string. :rtype: str """ + if not isinstance(mnemonic, IMnemonic): + mnemonic = BIP39Mnemonic(mnemonic=mnemonic, language=language) + assert isinstance(mnemonic, BIP39Mnemonic) - mnemonic = ( - mnemonic.mnemonic() - if isinstance(mnemonic, IMnemonic) else - mnemonic - ) - return BIP39Seed.from_mnemonic(mnemonic=mnemonic, passphrase=passphrase) + return BIP39Seed.from_mnemonic(mnemonic=mnemonic.mnemonic(), language=mnemonic.language(), passphrase=passphrase) @classmethod - def generate_byron_legacy(cls, mnemonic: Union[str, IMnemonic]) -> str: + def generate_byron_legacy(cls, mnemonic: Union[str, IMnemonic], language: Optional[str] = None) -> str: """ Generates a Byron Legacy seed from a given mnemonic phrase. @@ -222,21 +216,16 @@ def generate_byron_legacy(cls, mnemonic: Union[str, IMnemonic]) -> str: :return: The derived Byron Legacy seed as a string. :rtype: str """ - - mnemonic = ( - mnemonic.mnemonic() - if isinstance(mnemonic, IMnemonic) else - mnemonic - ) - if not BIP39Mnemonic.is_valid(mnemonic=mnemonic): - raise MnemonicError(f"Invalid {BIP39Mnemonic.name()} mnemonic words") + if not isinstance(mnemonic, IMnemonic): + mnemonic = BIP39Mnemonic(mnemonic=mnemonic, language=language) + assert isinstance(mnemonic, BIP39Mnemonic) return bytes_to_string(blake2b_256( - cbor2.dumps(get_bytes(BIP39Mnemonic.decode(mnemonic=mnemonic))) + cbor2.dumps(get_bytes(BIP39Mnemonic.decode(mnemonic=mnemonic.mnemonic(), language=mnemonic.language()))) )) @classmethod - def generate_shelley_icarus(cls, mnemonic: Union[str, IMnemonic]) -> str: + def generate_shelley_icarus(cls, mnemonic: Union[str, IMnemonic], language: Optional[str] = None) -> str: """ Generates a Shelley Icarus seed from a given mnemonic phrase. @@ -248,11 +237,11 @@ def generate_shelley_icarus(cls, mnemonic: Union[str, IMnemonic]) -> str: """ return cls.generate_byron_icarus( - mnemonic=mnemonic + mnemonic=mnemonic, language=language ) @classmethod - def generate_shelley_ledger(cls, mnemonic: str, passphrase: Optional[str] = None) -> str: + def generate_shelley_ledger(cls, mnemonic: str, passphrase: Optional[str] = None, language: Optional[str] = None) -> str: """ Generates a Shelley ledger seed from a given mnemonic phrase and optional passphrase. @@ -266,5 +255,5 @@ def generate_shelley_ledger(cls, mnemonic: str, passphrase: Optional[str] = None """ return cls.generate_byron_ledger( - mnemonic=mnemonic, passphrase=passphrase + mnemonic=mnemonic, passphrase=passphrase, language=language ) diff --git a/hdwallet/seeds/electrum/v1.py b/hdwallet/seeds/electrum/v1.py index 5909c115..8b98a12b 100644 --- a/hdwallet/seeds/electrum/v1.py +++ b/hdwallet/seeds/electrum/v1.py @@ -4,7 +4,7 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or https://opensource.org/license/mit -from typing import Union +from typing import Optional, Union from ...crypto import sha256 from ...exceptions import MnemonicError @@ -42,7 +42,12 @@ def name(cls) -> str: return "Electrum-V1" @classmethod - def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], **kwargs) -> str: + def from_mnemonic( + cls, + mnemonic: Union[str, IMnemonic], + language: Optional[str] = None, + **kwargs + ) -> str: """ Converts an Electrum V1 mnemonic phrase to its corresponding hashed entropy. @@ -52,14 +57,11 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], **kwargs) -> str: :return: The hashed entropy as a string. :rtype: str """ + if not isinstance(mnemonic, IMnemonic): + mnemonic = ElectrumV1Mnemonic(mnemonic=mnemonic, language=language) + assert isinstance(mnemonic, ElectrumV1Mnemonic) - mnemonic = ( - mnemonic.mnemonic() if isinstance(mnemonic, IMnemonic) else mnemonic - ) - if not ElectrumV1Mnemonic.is_valid(mnemonic=mnemonic): - raise MnemonicError(f"Invalid {cls.name()} mnemonic words") - - entropy: str = ElectrumV1Mnemonic.decode(mnemonic) + entropy: str = ElectrumV1Mnemonic.decode(mnemonic=mnemonic.mnemonic(), language=mnemonic.language(), **kwargs) entropy_hash: bytes = encode(entropy) for _ in range(cls.hash_iteration_number): entropy_hash = sha256(entropy_hash + encode(entropy)) diff --git a/hdwallet/seeds/electrum/v2.py b/hdwallet/seeds/electrum/v2.py index b30f802d..2f3644e7 100644 --- a/hdwallet/seeds/electrum/v2.py +++ b/hdwallet/seeds/electrum/v2.py @@ -51,6 +51,7 @@ def from_mnemonic( cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None, + language: Optional[str] = None, mnemonic_type=ELECTRUM_V2_MNEMONIC_TYPES.STANDARD ) -> str: """ @@ -66,16 +67,13 @@ def from_mnemonic( :return: The derived seed as a string. :rtype: str """ - - mnemonic = ( - mnemonic.mnemonic() if isinstance(mnemonic, IMnemonic) else mnemonic - ) - if not ElectrumV2Mnemonic.is_valid(mnemonic=mnemonic, mnemonic_type=mnemonic_type): - raise MnemonicError(f"Invalid {cls.name()} mnemonic words") + if not isinstance(mnemonic, IMnemonic): + mnemonic = ElectrumV2Mnemonic(mnemonic=mnemonic, language=language) + assert isinstance(mnemonic, ElectrumV2Mnemonic) salt: str = unicodedata.normalize("NFKD", ( (cls.seed_salt_modifier + passphrase) if passphrase else cls.seed_salt_modifier )) return bytes_to_string(pbkdf2_hmac_sha512( - password=mnemonic, salt=salt, iteration_num=cls.seed_pbkdf2_rounds + password=unicodedata.normalize("NFKD", mnemonic.mnemonic()), salt=salt, iteration_num=cls.seed_pbkdf2_rounds )) diff --git a/hdwallet/seeds/iseed.py b/hdwallet/seeds/iseed.py index dda8a840..6807342a 100644 --- a/hdwallet/seeds/iseed.py +++ b/hdwallet/seeds/iseed.py @@ -7,7 +7,7 @@ from abc import ( ABC, abstractmethod ) -from typing import Union +from typing import Optional, Union import re @@ -58,6 +58,11 @@ def seed(self) -> str: """ Retrieves the seed associated with the current instance. + :param mnemonic: The mnemonic phrase to be decoded. Can be a string or an instance of `IMnemonic`. + :type mnemonic: Union[str, IMnemonic] + :param language: The preferred language, if known + :type language: Optional[str] + :return: The seed as a string. :rtype: str """ @@ -66,5 +71,5 @@ def seed(self) -> str: @classmethod @abstractmethod - def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], **kwargs) -> str: + def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], language: Optional[str], **kwargs) -> str: pass diff --git a/hdwallet/seeds/monero.py b/hdwallet/seeds/monero.py index 3df5433c..df4bcbd7 100644 --- a/hdwallet/seeds/monero.py +++ b/hdwallet/seeds/monero.py @@ -4,7 +4,7 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or https://opensource.org/license/mit -from typing import Union +from typing import Optional, Union from ..exceptions import MnemonicError from ..mnemonics import ( @@ -38,7 +38,7 @@ def name(cls) -> str: return "Monero" @classmethod - def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], **kwargs) -> str: + def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], language: Optional[str] = None, **kwargs) -> str: """ Converts a mnemonic phrase to its corresponding seed. @@ -48,10 +48,8 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], **kwargs) -> str: :return: The decoded entropy as a string. :rtype: str """ - mnemonic = ( - mnemonic.mnemonic() if isinstance(mnemonic, IMnemonic) else mnemonic - ) - if not MoneroMnemonic.is_valid(mnemonic=mnemonic): - raise MnemonicError(f"Invalid {cls.name()} mnemonic words") + if not isinstance(mnemonic, IMnemonic): + mnemonic = MoneroMnemonic(mnemonic=mnemonic, language=language) + assert isinstance(mnemonic, MoneroMnemonic) - return MoneroMnemonic.decode(mnemonic=mnemonic) + return MoneroMnemonic.decode(mnemonic=mnemonic.mnemonic(), language=mnemonic.language()) diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index 1038e283..f444f094 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -39,7 +39,7 @@ def name(cls) -> str: return "SLIP39" @classmethod - def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None) -> str: + def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None, language: Optional[str] = None) -> str: """Converts a mnemonic phrase to its corresponding raw entropy. The Mnemonic representation for SLIP-39 seeds is simple hex, and must be of the supported @@ -76,8 +76,8 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str ] for M in allowed_entropy: - if M.is_valid(mnemonic): - mnemonic = M(mnemonic=mnemonic) + if M.is_valid(mnemonic, language=language): + mnemonic = M(mnemonic=mnemonic, language=language) break else: raise EntropyError( @@ -85,7 +85,7 @@ def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str ) # Some kind of IMnemonic (eg. a BIP39Mnemonic); get and return its raw entropy as hex - entropy = mnemonic.decode(mnemonic.mnemonic()) + entropy = mnemonic.decode(mnemonic=mnemonic.mnemonic(), language=mnemonic.language()) if len(entropy) * 4 not in SLIP39Mnemonic.words_to_entropy_strength.values(): raise EntropyError( "Invalid entropy size in bits", expected=SLIP39Mnemonic.words_to_entropy_strength.values(), got=len(entropy) * 4, diff --git a/tests/cli/test_cli_mnemonic.py b/tests/cli/test_cli_mnemonic.py index ab48f0b6..6b34440c 100644 --- a/tests/cli/test_cli_mnemonic.py +++ b/tests/cli/test_cli_mnemonic.py @@ -6,6 +6,7 @@ # file COPYING or https://opensource.org/license/mit import json +import unicodedata from hdwallet.cli.__main__ import cli_main @@ -19,24 +20,38 @@ def check_mnemonics( entropy, mnemonic ): - - output_word = json.loads(cli_word.output) - output_entropy = json.loads(cli_entropy.output) - - assert cli_word.exit_code == 0 - assert cli_entropy.exit_code == 0 - - assert output_word["client"] == client - assert output_entropy["client"] == client - - assert output_word["words"] == words - assert output_entropy["words"] == words - - assert output_word["language"].lower() == language - assert output_entropy["language"].lower() == language - - assert output_entropy["mnemonic"] == mnemonic - + def json_parser( json_i ): + json_s = ''.join( json_i ) + try: + data = json.loads(json_s) + except Exception as exc: + print( f"Failed to parse JSON: {exc} from:\n{json_s}" ) + raise + return data + + try: + + output_word = json_parser( cli_word.output ) + output_entropy = json_parser( cli_entropy.output ) + + assert cli_word.exit_code == 0 + assert cli_entropy.exit_code == 0 + + assert output_word["client"] == client + assert output_entropy["client"] == client + + assert output_word["words"] == words + assert output_entropy["words"] == words + + assert output_word["language"].lower() == language + assert output_entropy["language"].lower() == language + + # Mnemonics recovered will be in + assert unicodedata.normalize( "NFC", mnemonic ) == unicodedata.normalize( "NFC", output_entropy["mnemonic"] ) + + except Exception as exc: + print( f"Failed {client} w/ {language} mnemonic: {mnemonic}: {exc}" ) + raise def test_cli_mnemonic(data, cli_tester): diff --git a/tests/cli/test_cli_seed.py b/tests/cli/test_cli_seed.py index de262e64..9ebfa4cb 100644 --- a/tests/cli/test_cli_seed.py +++ b/tests/cli/test_cli_seed.py @@ -21,7 +21,8 @@ def test_cli_seed(data, cli_tester): "generate", "seed", "--client", client, "--mnemonic-type", mnemonic_type, - "--mnemonic", data["seeds"][client][words][mnemonic_type][language]["mnemonic"] + "--mnemonic", data["seeds"][client][words][mnemonic_type][language]["mnemonic"], + "--language", language, ] if data["seeds"][client][words][mnemonic_type][language]["passphrases"] != None: @@ -31,9 +32,11 @@ def test_cli_seed(data, cli_tester): else: seed = data["seeds"][client][words][mnemonic_type][language]["non-passphrase-seed"] + print(" ".join(cli_args)) cli = cli_tester.invoke( cli_main, cli_args ) + print(f" --> {cli.output}") output = json.loads(cli.output) assert output["client"] == client @@ -45,7 +48,8 @@ def test_cli_seed(data, cli_tester): "generate", "seed", "--client", client, "--cardano-type", cardano_type, - "--mnemonic", data["seeds"][client][words][cardano_type][language]["mnemonic"] + "--mnemonic", data["seeds"][client][words][cardano_type][language]["mnemonic"], + "--language", language, ] if data["seeds"][client][words][cardano_type][language]["passphrases"] != None: @@ -55,9 +59,11 @@ def test_cli_seed(data, cli_tester): else: seed = data["seeds"][client][words][cardano_type][language]["non-passphrase-seed"] + print(" ".join(cli_args)) cli = cli_tester.invoke( cli_main, cli_args ) + print(f" --> {cli.output}") output = json.loads(cli.output) assert output["client"] == client @@ -67,7 +73,8 @@ def test_cli_seed(data, cli_tester): cli_args = [ "generate", "seed", "--client", client, - "--mnemonic", data["seeds"][client][words][language]["mnemonic"] + "--mnemonic", data["seeds"][client][words][language]["mnemonic"], + "--language", language, ] if data["seeds"][client][words][language]["passphrases"] != None: @@ -77,9 +84,11 @@ def test_cli_seed(data, cli_tester): else: seed = data["seeds"][client][words][language]["non-passphrase-seed"] + print(" ".join(cli_args)) cli = cli_tester.invoke( cli_main, cli_args ) + print(f" --> {cli.output}") output = json.loads(cli.output) assert output["client"] == client diff --git a/tests/data/json/mnemonics.json b/tests/data/json/mnemonics.json index c6cd5146..8ea1aa19 100644 --- a/tests/data/json/mnemonics.json +++ b/tests/data/json/mnemonics.json @@ -317,16 +317,16 @@ "words": 12, "checksum": false, "languages": { - "chinese-simplified": "宽 事 理 密 道 催 霸 挂 制 妇 出 间", - "dutch": "motregen benadeeld atrium geslaagd bizar traktaat zulk sneu badderen robot afspeel best", "english": "mundane aunt answers gang beer thirsty zebra shuffled apricot rest affair awakened", + "dutch": "motregen benadeeld atrium geslaagd bizar traktaat zulk sneu badderen robot afspeel best", "french": "moudre avide arcade feuille baver site vrac repli article promener adresser azote", "german": "jäger ansporn amphibie erosion auftritt salat zollhund pedal anbieten muster abteil aorta", "italian": "medaglia arte andare egiziano aviatore specchio zattera rumore annuncio pupazzo affogato aspirina", "japanese": "ずっしり いもたれ いせき けおりもの うけつけ なげる ひかん ていおん いちど だんねつ あぶる いらい", "portuguese": "loquaz atazanar anfora faturista baqueta solver zabumba recusavel apito pecuniario adriatico aturdir", "russian": "октябрь билет ателье иметь бугор уран январь сфера аэропорт сердце аксиома бицепс", - "spanish": "hurto apio alteza derrota asa pasión rifa nube ámbar moda acuerdo aprobar" + "spanish": "hurto apio alteza derrota asa pasión rifa nube ámbar moda acuerdo aprobar", + "chinese-simplified": "宽 事 理 密 道 催 霸 挂 制 妇 出 间" } }, { @@ -335,16 +335,16 @@ "words": 13, "checksum": true, "languages": { - "chinese-simplified": "纸 呀 刑 斜 足 措 监 联 末 喊 穷 咱 监", - "dutch": "leguaan nullijn knaven pablo hekman nylon rein diode napijn tuma tout tulp nylon", "english": "leopard noted jingle pancakes hairy nowhere ravine dauntless nasty today testing tobacco tobacco", + "dutch": "leguaan nullijn knaven pablo hekman nylon rein diode napijn tuma tout tulp nylon", "french": "litre norme jardin parler fuser notre poupe cirage muse sombre singe solvant litre", "german": "habicht kaugummi gehweg lausbub fazit keilerei monat brandung jupiter schmied sachbuch schlager schlager", "italian": "insalata muraglia giusto panfilo farmacia musica primario carbone mettere spuntare spagnolo spumante spumante", "japanese": "じかん せつだん さぎょう そむりえ けんにん せつび たれる おどり すぼん なやむ ないせん なめる なやむ", "portuguese": "itrio miau icar noturno fumo migratorio ozonizar coordenativa luxuriante sujo sodomizar suite ozonizar", "russian": "мятый оценка мачта пузырь клятва очищать сбегать гильза орган уцелеть упасть уходить клятва", - "spanish": "gen jornada finca llama eficaz joya miembro burbuja imponer pelar parque peine imponer" + "spanish": "gen jornada finca llama eficaz joya miembro burbuja imponer pelar parque peine imponer", + "chinese-simplified": "纸 呀 刑 斜 足 措 监 联 末 喊 穷 咱 监" } }, { @@ -353,16 +353,16 @@ "words": 24, "checksum": false, "languages": { - "chinese-simplified": "李 舞 感 炼 熔 黑 能 铜 毒 炮 枝 柬 留 节 违 朗 株 纳 卡 坦 周 意 南 阀", - "dutch": "giebelen smoel fabel saksisch vieux hiaat alikruik pixel pieneman riskant tragedie soapbox ionisch eind tosti sediment sulfaat pakzadel peuk rugpijn eurocent boei dagprijs tabak", "english": "gemstone sewage equip runway unknown haystack ahead playful pigment reorder textbook sidekick identity drunk template satin sovereign pastry physics roomy emotion betting cohesive spying", + "dutch": "giebelen smoel fabel saksisch vieux hiaat alikruik pixel pieneman riskant tragedie soapbox ionisch eind tosti sediment sulfaat pakzadel peuk rugpijn eurocent boei dagprijs tabak", "french": "fils rendre doute raie tanin galop aigre pieu perte produire sinus respect haie crin sigle ratio rubis partir perdu quitter docteur bidule certes rustre", "german": "erwidern partner druck obdach taktung feldbett afrika lümmel lohn mumie saft person foliant cousin rüstung omelett quote leder locken neugier donner aussage biologe rapsöl", "italian": "emettere rotonda crimine rete tenebre femmina aiutare petrolio pentirsi pugilato sparire sacco forzare cigno sospiro rilevare scuola panorama pendenza recitare corrente babbuino cacciare seme", "japanese": "けさき つるみく きない ちめいど ぬまえび こいぬ あらゆる たいまつばな だいたい たんてい なおす ていこく こつぶ がっしょう どんぶり つうじょう てんき そよかぜ だいじょうぶ ちたい きすう うすい おおよそ てんぷら", "portuguese": "felicidade rasurar ejetar poquer toar gado agito oigale ocre pavoroso software regurgitar guaxinim diatribe slide pueril ruas nublar oceanografo pizzaria ecumenismo begonia chuvoso rural", "russian": "инфекция суровый емкость совет хозяин кодекс амбар ремонт рвать секунда упор счет ледяной держать умолять спать ткань пурга рапорт случай дыра бухта выгодный трибуна", - "spanish": "diablo norte cocina muro pleno élite agitar malo macho mitad parte nudo etnia carro parcela náusea olor llegar lustro mozo circo asno bondad óptica" + "spanish": "diablo norte cocina muro pleno élite agitar malo macho mitad parte nudo etnia carro parcela náusea olor llegar lustro mozo circo asno bondad óptica", + "chinese-simplified": "李 舞 感 炼 熔 黑 能 铜 毒 炮 枝 柬 留 节 违 朗 株 纳 卡 坦 周 意 南 阀" } }, { @@ -371,16 +371,16 @@ "words": 25, "checksum": true, "languages": { - "chinese-simplified": "往 祥 钢 众 砂 筒 完 阵 挂 勤 爱 氮 黄 斯 辉 起 永 接 腿 喝 教 碳 烯 做 起", - "dutch": "faliekant wals isaac erna whirlpool scout eenruiter ponywagen sneu twitteren guido vrekkig gekskap folder zijbeuk atsma omdoen clicheren spiraal valreep drol oudachtig soigneren cruijff sneu", "english": "error value idiom efficient vocal sanity donuts poker shuffled together greater upstairs fuming farming womanly antics obnoxious cafe slackens tsunami dewdrop oyster sifting civilian womanly", + "dutch": "faliekant wals isaac erna whirlpool scout eenruiter ponywagen sneu twitteren guido vrekkig gekskap folder zijbeuk atsma omdoen clicheren spiraal valreep drol oudachtig soigneren cruijff sneu", "french": "drame toque haine devoir tutu raser coupure pirate repli sondage foyer texte faune enfermer visite arceau obtenir cabinet rigide stagiaire cocon panda reste cavale cabinet", "german": "dünung traum folklore dezibel verb oktave chipsatz maibaum pedal schrank familie ticken erfüllen einfall wrack ampulle klee beladen polieren sitzbank bugwelle langmut pfa bezahlen familie", "italian": "cronaca tromba fosforo comune vaglio riferire chirurgo pigiama rumore staccare evacuare topolino ebbrezza deposito virgola androide nipote bombola sberla stufa cauzione padella sale buffo cauzione", "japanese": "きねん ねんぶつ こてい きかく はいご ちんもく かがし たえる ていおん ならぶ けろけろ ねほりはほり ぐんて きわめる はんかく いぜん せんげん えすて てすり にっけい おんどけい そなえる ていし おうじ おんどけい", "portuguese": "elmo umero gude druso valvula pterossauros deltoide olvidavel recusavel suntuoso fominha tufo facultativo enzimatico vozes aniversario morubixaba bucolico ridiculo tatuar cupula noel rejuvenescimento ceifeiro druso", "russian": "жажда членство лежать древний шикарный соус двор рифма сфера учитель кибитка цирк излагать замечать эскиз атлас пачка вишневый таможня финал гость пруд сшивать всадник атлас", - "spanish": "cofre producto evadir chiste quince nasal capitán manso nube peligro dulce posible dedo cordón rencor altivo lacio barba ocaso pesa calamar linterna nueve bobina nueve" + "spanish": "cofre producto evadir chiste quince nasal capitán manso nube peligro dulce posible dedo cordón rencor altivo lacio barba ocaso pesa calamar linterna nueve bobina nueve", + "chinese-simplified": "往 祥 钢 众 砂 筒 完 阵 挂 勤 爱 氮 黄 斯 辉 起 永 接 腿 喝 教 碳 烯 做 起" } } ] diff --git a/tests/data/json/seeds.json b/tests/data/json/seeds.json index 163e5a10..679b3d0e 100644 --- a/tests/data/json/seeds.json +++ b/tests/data/json/seeds.json @@ -503,31 +503,26 @@ "SLIP39": { "12": { "english": { - "mnemonic": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", - "non-passphrase-seed": "ffffffffffffffffffffffffffffffff", - "passphrases": null - }, - "hex-one-twenty-eight": { "mnemonic": "ffffffffffffffffffffffffffffffff", "non-passphrase-seed": "ffffffffffffffffffffffffffffffff", "passphrases": null }, - "hex-two-fifty-six": { + "english": { "mnemonic": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "passphrases": null }, - "hex-five-twelve": { + "english": { "mnemonic": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "passphrases": null }, - "five-twelve-one-of-one": { + "english": { "mnemonic": "edge typical academic academic academic boring radar cluster domestic ticket fumes remove velvet fluff video crazy chest average script universe exhaust remind helpful lamp declare garlic repeat unknown bucket adorn sled adult triumph source divorce premium genre glimpse level listen ancestor wildlife writing document wrist judicial medical detect frost leaves language jerky increase glasses extra alto deploy demand greatest", "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "passphrases": null }, - "five-twelve-two-of-two": { + "english": { "mnemonic": "bracelet cleanup acrobat easy acquire critical exceed agency verify envy story best facility process syndrome discuss health twin ugly spew unknown spider level academic lying large slap venture hairy election legal away negative easel learn item trial miracle hour provide survive pleasure clock acne faint priest loyalty sunlight award forget ambition failure threaten kind dictate lips branch slice space\nbracelet cleanup beard easy acne visitor scroll finger skin trash browser union energy endorse scramble staff sprinkle salt alpha dive sweater pickup cage obtain leader clothes acid dive frozen category desert thorn music western home owner manager apart much hobo march adequate eraser crazy short smith force flame primary phrase sprinkle frost trial crunch fancy piece crunch scroll triumph", "non-passphrase-seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "passphrases": null @@ -2468,14 +2463,6 @@ "Electrum-V2": { "12": { "standard": { - "chinese-simplified": { - "mnemonic": "轧 驻 省 刮 碰 示 拖 黎 典 班 其 抹", - "non-passphrase-seed": "9c5af65ec3d73641f7a691ab41185ca3217d5592a3356547e4d9ff3d4f13b812f0edff3734d271f463935fe25bdd5699882a4594941f210355368bdddb0de607", - "passphrases": { - "hdwallet": "371cc005017bc30ba69d36fb16843c488562e20ed49c203204b94c9120c5a1e38f988eca33474541b4b4aed81f3dc0215b018d29c895f107861e819bfd7620c2", - "ゆしゅつ": "e435d04ca4213f9327e858c97a53c297860fa416ec9b2b4e3df2aebda08fe69e389c102b3c7ece7735e116ffa6b33bf988c4f0b6342637722872fab8f3a6414a" - } - }, "english": { "mnemonic": "gold engine arch point merge review extend diesel allow negative act night", "non-passphrase-seed": "45bc3118e9de3719d677440ca5a6df51e8528b8660f902a87493e08b019f1cb9755aee31503737e604d13b7cc61346583f36c795ded8a359729d80ee96845873", @@ -2499,6 +2486,14 @@ "hdwallet": "ec01762b64aa958965872608d5911126648776e9da5e303c68744d16540e5dd0e1ae93ecc739d9c03faea4829e83e1957b2b5692fb865a98fc8f9ffcd29b8a8f", "ゆしゅつ": "7ed4b7c6a21eb6359922eaf7d5a6a1ef284316a0d7873e839570595452060ee5426e6b961d20ea21546525777890b857ddd118efd335d346dc456d677a2cf7bf" } + }, + "chinese-simplified": { + "mnemonic": "轧 驻 省 刮 碰 示 拖 黎 典 班 其 抹", + "non-passphrase-seed": "9c5af65ec3d73641f7a691ab41185ca3217d5592a3356547e4d9ff3d4f13b812f0edff3734d271f463935fe25bdd5699882a4594941f210355368bdddb0de607", + "passphrases": { + "hdwallet": "371cc005017bc30ba69d36fb16843c488562e20ed49c203204b94c9120c5a1e38f988eca33474541b4b4aed81f3dc0215b018d29c895f107861e819bfd7620c2", + "ゆしゅつ": "e435d04ca4213f9327e858c97a53c297860fa416ec9b2b4e3df2aebda08fe69e389c102b3c7ece7735e116ffa6b33bf988c4f0b6342637722872fab8f3a6414a" + } } }, "segwit": { diff --git a/tests/data/raw/languages.txt b/tests/data/raw/languages.txt index 5c41671a..0af39e74 100644 --- a/tests/data/raw/languages.txt +++ b/tests/data/raw/languages.txt @@ -5,18 +5,18 @@ English BIP39 Languages ------------------- -Chinese-Simplified -Chinese-Traditional -Czech English French +Spanish Italian -Japanese -Korean -Portuguese Russian -Spanish +Portuguese +Czech Turkish +Korean +Chinese-Simplified +Chinese-Traditional +Japanese SLIP39 Languages @@ -31,21 +31,21 @@ English Electrum-V2 Languages ----------------------- -Chinese-Simplified English -Portuguese Spanish +Portuguese +Chinese-Simplified Monero Languages ------------------ -Chinese-Simplified -Dutch English French +Spanish German +Dutch Italian -Japanese -Portuguese Russian -Spanish +Portuguese +Japanese +Chinese-Simplified diff --git a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v1.py b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v1.py index ce90f8df..84f03ca6 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v1.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v1.py @@ -38,14 +38,14 @@ def test_electrum_v1_mnemonics(data): assert ElectrumV1Mnemonic.from_entropy(entropy=__["entropy"], language=language) == __["languages"][language] assert ElectrumV1Mnemonic.decode(mnemonic=__["languages"][language]) == __["entropy"] - mnemonic = ElectrumV1Mnemonic(mnemonic=__["languages"][language]) + mnemonic = ElectrumV1Mnemonic(mnemonic=__["languages"][language], language=language) assert mnemonic.name() == __["name"] assert mnemonic.language().lower() == language - with pytest.raises(Exception, match="Invalid mnemonic words"): + with pytest.raises(Exception, match="Invalid Electrum-V1 mnemonic words"): ElectrumV1Mnemonic( - mnemonic="flower letter world foil coin poverty romance tongue taste hip cradle follow proud pluck ten improve" + mnemonic="flower letter world foil coin poverty romance tongue taste hip cradle follow proud pluck ten improve", ) with pytest.raises(MnemonicError, match="Invalid mnemonic words number"): diff --git a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py index 67faf9fb..b1d7ffcc 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py @@ -35,28 +35,35 @@ def test_electrum_v2_mnemonics(data): for language in __["mnemonic-types"][mnemonic_type].keys(): assert ElectrumV2Mnemonic.is_valid_language(language=language) + mnemonic_words=__["mnemonic-types"][mnemonic_type][language]["mnemonic"] + try: + print( ElectrumV2Mnemonic.decode(mnemonic=mnemonic_words, language=language, mnemonic_type=mnemonic_type) ) + except Exception as exc: + import traceback + print( f"Failed for {mnemonic_words}: {traceback.format_exc()}" ) assert ElectrumV2Mnemonic.is_valid( - mnemonic=__["mnemonic-types"][mnemonic_type][language]["mnemonic"], mnemonic_type=mnemonic_type + mnemonic=mnemonic_words, language=language, mnemonic_type=mnemonic_type ) mnemonic = ElectrumV2Mnemonic.from_words(words=__["words"], language=language, mnemonic_type=mnemonic_type) assert len(mnemonic.split()) == __["words"] - assert ElectrumV2Mnemonic(mnemonic=mnemonic, mnemonic_type=mnemonic_type).language().lower() == language + assert ElectrumV2Mnemonic(mnemonic=mnemonic, language=language, mnemonic_type=mnemonic_type).language().lower() == language assert ElectrumV2Mnemonic.from_entropy( entropy=__["entropy-not-suitable"], mnemonic_type=mnemonic_type, language=language ) == __["mnemonic-types"][mnemonic_type][language]["mnemonic"] assert ElectrumV2Mnemonic.decode( - mnemonic=__["mnemonic-types"][mnemonic_type][language]["mnemonic"], mnemonic_type=mnemonic_type + mnemonic=__["mnemonic-types"][mnemonic_type][language]["mnemonic"], language=language, mnemonic_type=mnemonic_type ) == __["mnemonic-types"][mnemonic_type][language]["entropy-suitable"] mnemonic = ElectrumV2Mnemonic( - mnemonic=__["mnemonic-types"][mnemonic_type][language]["mnemonic"], mnemonic_type=mnemonic_type + mnemonic=__["mnemonic-types"][mnemonic_type][language]["mnemonic"], language=language, mnemonic_type=mnemonic_type ) assert mnemonic.name() == __["name"] assert mnemonic.language().lower() == language assert mnemonic.mnemonic_type() == mnemonic_type + with pytest.raises(Exception, match="Invalid mnemonic words"): ElectrumV2Mnemonic( diff --git a/tests/hdwallet/mnemonics/test_mnemonics_monero.py b/tests/hdwallet/mnemonics/test_mnemonics_monero.py index de75e3db..17dcee29 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_monero.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_monero.py @@ -8,6 +8,7 @@ import json import os import pytest +import unicodedata from hdwallet.mnemonics.monero.mnemonic import ( MoneroMnemonic, MONERO_MNEMONIC_LANGUAGES, MONERO_MNEMONIC_WORDS @@ -39,18 +40,22 @@ def test_monero_mnemonics(data): assert MoneroMnemonic.is_valid_words(words=__["words"]) for language in __["languages"].keys(): - + print( f"Monero {language} mnemonics:" ) assert MoneroMnemonic.is_valid_language(language=language) - assert MoneroMnemonic.is_valid(mnemonic=__["languages"][language]) + try: + print( MoneroMnemonic.decode(mnemonic=__["languages"][language], language=language) ) + except Exception as exc: + print( exc ) + assert MoneroMnemonic.is_valid(mnemonic=__["languages"][language], language=language) mnemonic = MoneroMnemonic.from_words(words=__["words"], language=language) assert len(mnemonic.split()) == __["words"] - assert MoneroMnemonic(mnemonic=mnemonic).language().lower() == language + assert MoneroMnemonic(mnemonic=mnemonic, language=language).language().lower() == language - assert MoneroMnemonic.from_entropy(entropy=__["entropy"], checksum=__["checksum"], language=language) == __["languages"][language] - assert MoneroMnemonic.decode(mnemonic=__["languages"][language]) == __["entropy"] + assert MoneroMnemonic.from_entropy(entropy=__["entropy"], checksum=__["checksum"], language=language) == unicodedata.normalize("NFC", __["languages"][language]) + assert MoneroMnemonic.decode(mnemonic=__["languages"][language], language=language) == __["entropy"] - mnemonic = MoneroMnemonic(mnemonic=__["languages"][language]) + mnemonic = MoneroMnemonic(mnemonic=__["languages"][language], language=language) assert mnemonic.name() == __["name"] assert mnemonic.language().lower() == language diff --git a/tests/hdwallet/seeds/test_seeds_bip39.py b/tests/hdwallet/seeds/test_seeds_bip39.py index 400f2f6b..718ddb35 100644 --- a/tests/hdwallet/seeds/test_seeds_bip39.py +++ b/tests/hdwallet/seeds/test_seeds_bip39.py @@ -24,8 +24,3 @@ def test_bip39_seeds(data): assert BIP39Seed.from_mnemonic( mnemonic= data["seeds"]["BIP39"][words][lang]["mnemonic"], passphrase=passphrase ) == data["seeds"]["BIP39"][words][lang]["passphrases"][passphrase] - - assert BIP39Seed.from_mnemonic( - mnemonic= data["seeds"]["BIP39"][words][lang]["mnemonic"], passphrase=passphrase - ) == data["seeds"]["BIP39"][words][lang]["passphrases"][passphrase] - diff --git a/tests/hdwallet/seeds/test_seeds_cardano.py b/tests/hdwallet/seeds/test_seeds_cardano.py index 4acee83a..ba850c87 100644 --- a/tests/hdwallet/seeds/test_seeds_cardano.py +++ b/tests/hdwallet/seeds/test_seeds_cardano.py @@ -31,10 +31,3 @@ def test_cardano_seeds(data): passphrase=passphrase, cardano_type=cardano_type ) == data["seeds"]["Cardano"][words][cardano_type][lang]["passphrases"][passphrase] - - assert CardanoSeed.from_mnemonic( - mnemonic= data["seeds"]["Cardano"][words][cardano_type][lang]["mnemonic"], - passphrase=passphrase, - cardano_type=cardano_type - ) == data["seeds"]["Cardano"][words][cardano_type][lang]["passphrases"][passphrase] - diff --git a/tests/hdwallet/seeds/test_seeds_electrum_v2.py b/tests/hdwallet/seeds/test_seeds_electrum_v2.py index 2b0ffeb4..96d65b8e 100644 --- a/tests/hdwallet/seeds/test_seeds_electrum_v2.py +++ b/tests/hdwallet/seeds/test_seeds_electrum_v2.py @@ -17,21 +17,20 @@ def test_electrum_v2_seeds(data): for words in data["seeds"]["Electrum-V2"].keys(): for mnemonic_type in data["seeds"]["Electrum-V2"][words].keys(): - for lang in data["seeds"]["Electrum-V2"][words][mnemonic_type].keys(): - assert ElectrumV2Seed.from_mnemonic( - mnemonic= data["seeds"]["Electrum-V2"][words][mnemonic_type][lang]["mnemonic"], mnemonic_type=mnemonic_type - ) == data["seeds"]["Electrum-V2"][words][mnemonic_type][lang]["non-passphrase-seed"] - - for passphrase in data["seeds"]["Electrum-V2"][words][mnemonic_type][lang]["passphrases"].keys(): - assert ElectrumV2Seed.from_mnemonic( - mnemonic=data["seeds"]["Electrum-V2"][words][mnemonic_type][lang]["mnemonic"], - passphrase=passphrase, - mnemonic_type=mnemonic_type - ) == data["seeds"]["Electrum-V2"][words][mnemonic_type][lang]["passphrases"][passphrase] - + for language in data["seeds"]["Electrum-V2"][words][mnemonic_type].keys(): + mnemonic = data["seeds"]["Electrum-V2"][words][mnemonic_type][language]["mnemonic"] + non_passphrase_seed = ElectrumV2Seed.from_mnemonic( + mnemonic=mnemonic, + language=language, + mnemonic_type=mnemonic_type + ) + print(f"language: {language}: {mnemonic}: {non_passphrase_seed}") + assert non_passphrase_seed == data["seeds"]["Electrum-V2"][words][mnemonic_type][language]["non-passphrase-seed"] + + for passphrase in data["seeds"]["Electrum-V2"][words][mnemonic_type][language]["passphrases"].keys(): assert ElectrumV2Seed.from_mnemonic( - mnemonic=data["seeds"]["Electrum-V2"][words][mnemonic_type][lang]["mnemonic"], + mnemonic=mnemonic, passphrase=passphrase, + language=language, mnemonic_type=mnemonic_type - ) == data["seeds"]["Electrum-V2"][words][mnemonic_type][lang]["passphrases"][passphrase] - + ) == data["seeds"]["Electrum-V2"][words][mnemonic_type][language]["passphrases"][passphrase] From 77941d6c9b351a9e001a426613963bf04a444f72 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Tue, 30 Sep 2025 07:20:34 -0600 Subject: [PATCH 24/38] Correct Electrum V2 mnemonic type detection --- hdwallet/mnemonics/electrum/v2/mnemonic.py | 17 ++++++++----- hdwallet/mnemonics/imnemonic.py | 24 +++++++++---------- .../mnemonics/test_mnemonics_electrum_v2.py | 9 +++---- .../mnemonics/test_mnemonics_monero.py | 5 +--- 4 files changed, 27 insertions(+), 28 deletions(-) diff --git a/hdwallet/mnemonics/electrum/v2/mnemonic.py b/hdwallet/mnemonics/electrum/v2/mnemonic.py index 56d8811e..d71e3c4f 100644 --- a/hdwallet/mnemonics/electrum/v2/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v2/mnemonic.py @@ -8,6 +8,8 @@ Dict, List, Mapping, Union, Optional ) +import unicodedata + from ....entropies import ( IEntropy, ElectrumV2Entropy, ELECTRUM_V2_ENTROPY_STRENGTHS ) @@ -302,9 +304,9 @@ def encode( "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) ) - # Produces mnemonics of valid length, even if entropy has trailing zero value - while entropy > 0 or len(mnemonic) not in set(self.words_list): - word_index: int = entropy % cls.words_list_numbrer + # Produces mnemonics of valid length, even if entropy has trailing zero bits + while entropy > 0 or len(mnemonic) not in set(cls.words_list): + word_index: int = entropy % cls.words_list_number entropy //= cls.words_list_number mnemonic.append(words_list[word_index]) @@ -410,8 +412,10 @@ def is_valid( def is_type( cls, mnemonic: Union[str, List[str]], mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.STANDARD ) -> bool: - """ - Checks if the given mnemonic matches the specified mnemonic type. + """Checks if the given mnemonic matches the specified mnemonic type. + + All seed derivation related functions require NFKD Unicode normalization; + .normalize returns an NFC-normalized list of mnemonic words. :param mnemonic: The mnemonic phrase to check. :type mnemonic: str or List[str] @@ -420,9 +424,10 @@ def is_type( :return: True if the mnemonic matches the specified type, False otherwise. :rtype: bool + """ return bytes_to_string(hmac_sha512( - b"Seed version", " ".join(cls.normalize(mnemonic)) + b"Seed version", unicodedata.normalize("NFKD", " ".join(cls.normalize(mnemonic))) )).startswith( cls.mnemonic_types[mnemonic_type] ) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index f8858b33..89436a76 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -530,10 +530,10 @@ def wordlist_indices( @classmethod def find_language( - cls, - mnemonic: List[str], - wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, - language: Optional[str] = None, + cls, + mnemonic: List[str], + wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, + language: Optional[str] = None, ) -> Tuple[Mapping[str, int], str]: """Finds the language of the given mnemonic by checking against available word list(s), preferring the specified 'language' if one is supplied. If a 'wordlist_path' dict of @@ -615,10 +615,10 @@ def find_language( # preferred language (or no preferred language was specified). Keep track of its # quality of match, but carry on testing other candidate languages. except (MnemonicError, ValueError) as exc: - print( - f"Unrecognized mnemonic: {exc}" - # f" w/ indices:\n{words_indices}" - ) + # print( + # f"Unrecognized mnemonic: {exc}" + # # f" w/ indices:\n{words_indices}" + # ) continue # No unambiguous match to any preferred language found (or no language matched all words). @@ -658,10 +658,10 @@ def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = Non cls.decode(mnemonic=mnemonic, language=language, **kwargs) return True except (ValueError, MnemonicError, ChecksumError) as exc: - print( - f"Invalid mnemonic: {exc}" - # f" w/ indices:\n{words_indices}" - ) + # print( + # f"Invalid mnemonic: {exc}" + # # f" w/ indices:\n{words_indices}" + # ) return False @classmethod diff --git a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py index b1d7ffcc..e468ff4a 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py @@ -8,6 +8,7 @@ import json import os import pytest +import unicodedata from hdwallet.mnemonics.electrum.v2.mnemonic import ( ElectrumV2Mnemonic, ELECTRUM_V2_MNEMONIC_LANGUAGES, ELECTRUM_V2_MNEMONIC_WORDS @@ -36,11 +37,7 @@ def test_electrum_v2_mnemonics(data): assert ElectrumV2Mnemonic.is_valid_language(language=language) mnemonic_words=__["mnemonic-types"][mnemonic_type][language]["mnemonic"] - try: - print( ElectrumV2Mnemonic.decode(mnemonic=mnemonic_words, language=language, mnemonic_type=mnemonic_type) ) - except Exception as exc: - import traceback - print( f"Failed for {mnemonic_words}: {traceback.format_exc()}" ) + assert ElectrumV2Mnemonic.is_valid( mnemonic=mnemonic_words, language=language, mnemonic_type=mnemonic_type ) @@ -51,7 +48,7 @@ def test_electrum_v2_mnemonics(data): assert ElectrumV2Mnemonic.from_entropy( entropy=__["entropy-not-suitable"], mnemonic_type=mnemonic_type, language=language - ) == __["mnemonic-types"][mnemonic_type][language]["mnemonic"] + ) == unicodedata.normalize("NFC", __["mnemonic-types"][mnemonic_type][language]["mnemonic"]) assert ElectrumV2Mnemonic.decode( mnemonic=__["mnemonic-types"][mnemonic_type][language]["mnemonic"], language=language, mnemonic_type=mnemonic_type diff --git a/tests/hdwallet/mnemonics/test_mnemonics_monero.py b/tests/hdwallet/mnemonics/test_mnemonics_monero.py index 17dcee29..9a81f12e 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_monero.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_monero.py @@ -42,10 +42,7 @@ def test_monero_mnemonics(data): for language in __["languages"].keys(): print( f"Monero {language} mnemonics:" ) assert MoneroMnemonic.is_valid_language(language=language) - try: - print( MoneroMnemonic.decode(mnemonic=__["languages"][language], language=language) ) - except Exception as exc: - print( exc ) + assert MoneroMnemonic.is_valid(mnemonic=__["languages"][language], language=language) mnemonic = MoneroMnemonic.from_words(words=__["words"], language=language) From 17e88382bb40b642e1637f8c1422789fd95f1c30 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Fri, 3 Oct 2025 06:52:15 -0700 Subject: [PATCH 25/38] Improve handling of electrum word lists, cache wordlist indices --- hdwallet/mnemonics/bip39/mnemonic.py | 8 +- hdwallet/mnemonics/electrum/v1/mnemonic.py | 8 +- hdwallet/mnemonics/electrum/v2/mnemonic.py | 20 +- hdwallet/mnemonics/imnemonic.py | 42 ++- .../mnemonics/test_mnemonics_electrum_v2.py | 2 +- tests/test_unicode_normalization.py | 266 ------------------ 6 files changed, 50 insertions(+), 296 deletions(-) diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index b06b02ff..2ecdf819 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -286,10 +286,10 @@ def decode( raise Error( f"Must provide language with words_list" ) wordlist_path = { language: words_list } words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) - if len(set(words_list_with_index.values())) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) - ) + if len(words_list_with_index) != cls.words_list_number: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list_with_index) + ) mnemonic_bin: str = "".join(map( lambda word: integer_to_binary_string( diff --git a/hdwallet/mnemonics/electrum/v1/mnemonic.py b/hdwallet/mnemonics/electrum/v1/mnemonic.py index 4295444a..c78dd14c 100644 --- a/hdwallet/mnemonics/electrum/v1/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v1/mnemonic.py @@ -213,10 +213,10 @@ def decode( assert language, f"Must provide language with words_list" wordlist_path = { language: words_list } words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) - if len(set(words_list_with_index.values())) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) - ) + if len(words_list_with_index) != cls.words_list_number: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) + ) entropy: bytes = b"" for index in range(len(words) // 3): diff --git a/hdwallet/mnemonics/electrum/v2/mnemonic.py b/hdwallet/mnemonics/electrum/v2/mnemonic.py index d71e3c4f..6321ca0f 100644 --- a/hdwallet/mnemonics/electrum/v2/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v2/mnemonic.py @@ -224,15 +224,13 @@ def from_entropy( words_list: List[str] = cls.get_words_list_by_language( language=language, wordlist_path=cls.wordlist_path ) - bip39_words_list: List[str] = cls.get_words_list_by_language( - language=language, wordlist_path=BIP39Mnemonic.wordlist_path - ) + bip39_words_indices: Optional[List[str]] = None + (_, _ ,bip39_words_indices), = BIP39Mnemonic.wordlist_indices(language=language) + electrum_v1_words_indices: Optional[List[str]] = None try: - electrum_v1_words_list: List[str] = cls.get_words_list_by_language( - language=language, wordlist_path=ElectrumV1Mnemonic.wordlist_path - ) - except KeyError: - electrum_v1_words_list: Optional[List[str]] = None + (_, _, electrum_v1_words_indices), = ElectrumV1Mnemonic.wordlist_indices(language=language) + except ValueError: + pass entropy: int = bytes_to_integer(entropy) for index in range(max_attempts): @@ -243,10 +241,8 @@ def from_entropy( language=language, mnemonic_type=mnemonic_type, words_list=words_list, - bip39_words_list=bip39_words_list, - bip39_words_list_with_index=None, - electrum_v1_words_list=electrum_v1_words_list, - electrum_v1_words_list_with_index=None, + bip39_words_list_with_index=bip39_words_indices, + electrum_v1_words_list_with_index=electrum_v1_words_indices, ) except EntropyError: continue diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 89436a76..b0092234 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -11,12 +11,13 @@ abc ) from typing import ( - Any, Callable, Dict, Generator, List, Mapping, Optional, Set, Tuple, Union + Any, Callable, Dict, Generator, List, Mapping, Optional, Sequence, Set, Tuple, Union ) import os import string import unicodedata +from functools import lru_cache from collections import defaultdict @@ -241,7 +242,7 @@ class WordIndices( abc.Mapping ): .keys()[int(index)] """ - def __init__(self, sequence): + def __init__(self, sequence: Sequence[str]): """Insert a sequence of Unicode words (and optionally value(s)) into a Trie, making the "unmarked" version an alias of the regular Unicode version. @@ -270,6 +271,8 @@ def __init__(self, sequence): f"Attempting to alias {c_un!r} to {c!r} but already exists as a non-alias" n.children[c] = n.children[c_un] + #print( f"Created Mapping for {len(self)} words {', '.join(self._words[:min(len(self),3)])}...{self._words[-1]}" ) + def __getitem__(self, key: Union[str, int]) -> int: """A Mapping from "word" to index, or the reverse. @@ -511,21 +514,42 @@ def get_words_list_by_language( return words_list + @classmethod + @lru_cache(maxsize=32) + def _get_cached_word_indices(cls, wordlist_tuple: tuple[str]) -> WordIndices: + """Create and cache WordIndices for a given language and wordlist. + + :param language: The language name for identification + :type language: str + :param wordlist_tuple: Tuple of words (hashable for caching) + :type wordlist_tuple: tuple + + :return: Cached WordIndices object + :rtype: WordIndices + """ + return WordIndices(wordlist_tuple) + @classmethod def wordlist_indices( - cls, wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, + cls, wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, language: Optional[str] = None, ) -> Tuple[str, List[str], WordIndices]: """Yields each 'candidate' language, its NFKC-normalized words List, and its WordIndices - Mapping supporting indexing by 'int' word index, or 'str' with optional accents and all - unique abbreviations. + + Optionally restricts to the preferred language, if available. + + The WordIndices Mapping supporting indexing by 'int' word index, or 'str' with optional + accents and all unique abbreviations. """ for candidate in (wordlist_path.keys() if wordlist_path else cls.languages): + if language and candidate != language: + continue # Normalized NFC, so characters and accents are combined words_list: List[str] = cls.get_words_list_by_language( language=candidate, wordlist_path=wordlist_path ) - word_indices = WordIndices( words_list ) + # Convert to tuple for hashing, cache the WordIndices creation + word_indices = cls._get_cached_word_indices(tuple(words_list)) yield candidate, words_list, word_indices @classmethod @@ -640,8 +664,8 @@ def find_language( def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = None, **kwargs) -> bool: """Checks if the given mnemonic is valid. - Catches mnemonic-validity related Exceptions and returns False, but lets others through; - asserts, hdwallet.exceptions.Error, general programming errors, etc. + Catches mnemonic-validity related or word indexing Exceptions and returns False, but lets + others through; asserts, hdwallet.exceptions.Error, general programming errors, etc. :param mnemonic: The mnemonic to check. :type mnemonic: str @@ -657,7 +681,7 @@ def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = Non try: cls.decode(mnemonic=mnemonic, language=language, **kwargs) return True - except (ValueError, MnemonicError, ChecksumError) as exc: + except (ValueError, KeyError, MnemonicError, ChecksumError) as exc: # print( # f"Invalid mnemonic: {exc}" # # f" w/ indices:\n{words_indices}" diff --git a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py index e468ff4a..4c79b66b 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py @@ -34,7 +34,7 @@ def test_electrum_v2_mnemonics(data): for mnemonic_type in __["mnemonic-types"].keys(): for language in __["mnemonic-types"][mnemonic_type].keys(): - + print( f"Electrum {mnemonic_type} {language}:" ) assert ElectrumV2Mnemonic.is_valid_language(language=language) mnemonic_words=__["mnemonic-types"][mnemonic_type][language]["mnemonic"] diff --git a/tests/test_unicode_normalization.py b/tests/test_unicode_normalization.py index 948c2344..10f7256c 100644 --- a/tests/test_unicode_normalization.py +++ b/tests/test_unicode_normalization.py @@ -553,272 +553,6 @@ def test_accent_removal_with_bip39_words(self): print(f" Non-Latin '{word}' -> '{deaccented}' (should be unchanged)") assert deaccented == word, f"Non-Latin script should not change: '{word}' -> '{deaccented}'" - def test_character_category_analysis(self): - """Analyze Unicode character categories before and after normalization. - - This test examines the Unicode categories of characters in different scripts - to understand how NFKD/NFD decomposition affects character composition. - Categories include: - - L*: Letters (Lu=uppercase, Ll=lowercase, Lt=titlecase, Lm=modifier, Lo=other) - - M*: Marks/Combining (Mn=nonspacing, Mc=spacing, Me=enclosing) - - N*: Numbers (Nd=decimal, Nl=letter, No=other) - - P*: Punctuation - - S*: Symbols - - Z*: Separators - - C*: Control/Format - """ - - def analyze_character_categories(text: str, label: str) -> UnicodeAnalysis: - """Analyze character categories in a text string.""" - categories = [] - category_counts = {} - scripts = set() - character_details = [] - - for i, char in enumerate(text): - category = unicodedata.category(char) - name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') - - # Try to determine script from character name - script = self._determine_script_from_name(name) - if script: - scripts.add(script) - - char_info = CharacterInfo( - char=char, - ord=ord(char), - hex=f'U+{ord(char):04X}', - category=category, - name=name, - script=script - ) - - categories.append(category) - category_counts[category] = category_counts.get(category, 0) + 1 - character_details.append(char_info) - - return UnicodeAnalysis( - text=text, - label=label, - length=len(text), - categories=categories, - category_counts=category_counts, - scripts=scripts, - character_details=character_details - ) - - def compare_normalizations(text: str, label: str): - """Compare character categories across different normalizations.""" - print(f"\n=== Character Category Analysis: {label} ===") - print(f"Original text: '{text}'") - - # Analyze different normalization forms - forms = { - 'Original': text, - 'NFC': unicodedata.normalize('NFC', text), - 'NFD': unicodedata.normalize('NFD', text), - 'NFKC': unicodedata.normalize('NFKC', text), - 'NFKD': unicodedata.normalize('NFKD', text) - } - - analyses = {} - for form_name, form_text in forms.items(): - analyses[form_name] = analyze_character_categories(form_text, f"{label}-{form_name}") - - # Print summary comparison - print(f"Length changes: {', '.join(f'{name}={analysis.length}' for name, analysis in analyses.items())}") - - # Show category distribution for each form - for form_name, analysis in analyses.items(): - if analysis.category_counts: - categories_str = ', '.join(f"{cat}×{count}" for cat, count in sorted(analysis.category_counts.items())) - print(f"{form_name:8}: {categories_str}") - - # Show detailed character breakdown for NFD and NFKD (most detailed forms) - for form_name in ['NFD', 'NFKD']: - if form_name in analyses: - analysis = analyses[form_name] - if analysis.character_details: - print(f"\n{form_name} detailed breakdown:") - for char_info in analysis.character_details: - print(f" '{char_info.char}' {char_info.hex} {char_info.category} - {char_info.name}") - - return analyses - - print("\n=== Unicode Character Category Analysis ===") - - # Test cases covering different script types - test_cases = [ - # Latin script with accents - { - 'text': 'café', - 'label': 'Latin with acute accent', - 'expected_decomposition': True - }, - { - 'text': 'niño', - 'label': 'Latin with tilde', - 'expected_decomposition': True - }, - { - 'text': 'résumé', - 'label': 'Latin with multiple accents', - 'expected_decomposition': True - }, - # Greek script - { - 'text': 'αβγδε', - 'label': 'Greek basic letters', - 'expected_decomposition': False - }, - { - 'text': 'άλφα', # Greek with accent if available - 'label': 'Greek with accent', - 'expected_decomposition': True - }, - # Cyrillic script - { - 'text': 'привет', - 'label': 'Cyrillic basic letters', - 'expected_decomposition': False - }, - # Chinese script - { - 'text': '中文字符', - 'label': 'Chinese ideographs', - 'expected_decomposition': False - }, - # Japanese Hiragana - { - 'text': 'ひらがな', - 'label': 'Japanese Hiragana', - 'expected_decomposition': False - }, - # Japanese Katakana - { - 'text': 'カタカナ', - 'label': 'Japanese Katakana', - 'expected_decomposition': False - }, - # Korean - { - 'text': '한글문자', - 'label': 'Korean Hangul', - 'expected_decomposition': False - }, - # Arabic script - { - 'text': 'العربية', - 'label': 'Arabic script', - 'expected_decomposition': False - }, - # Hebrew script - { - 'text': 'עברית', - 'label': 'Hebrew script', - 'expected_decomposition': False - }, - # Compatibility characters - { - 'text': 'file', # Contains fi ligature - 'label': 'Latin with ligature', - 'expected_decomposition': True - }, - { - 'text': 'abc', # Fullwidth - 'label': 'Fullwidth Latin', - 'expected_decomposition': True - }, - { - 'text': 'café²', # Superscript - 'label': 'Latin with superscript', - 'expected_decomposition': True - } - ] - - # Run analysis on each test case - all_analyses = {} - for case in test_cases: - text = case['text'] - label = case['label'] - expected_decomp = case['expected_decomposition'] - - analyses = compare_normalizations(text, label) - all_analyses[label] = analyses - - # Verify our expectations about decomposition - original_len = analyses['Original'].length - nfd_len = analyses['NFD'].length - nfkd_len = analyses['NFKD'].length - - if expected_decomp: - # Should see length increase or category changes with NFD/NFKD - has_decomposition = (nfd_len > original_len or - nfkd_len > original_len or - analyses['NFD'].category_counts != analyses['Original'].category_counts or - analyses['NFKD'].category_counts != analyses['Original'].category_counts) - assert has_decomposition, f"Expected decomposition for {label} but none found" - else: - # Should remain mostly unchanged (unless NFKC/NFKD do compatibility transforms) - # Note: Some scripts might still have minor changes due to Unicode normalization - pass # We'll just observe the results for non-decomposing scripts - - # Summary analysis - print(f"\n=== Character Category Summary ===") - - # Collect all unique categories seen - all_categories = set() - for label, analyses in all_analyses.items(): - for form_name, analysis in analyses.items(): - all_categories.update(analysis.category_counts.keys()) - - print(f"All Unicode categories observed: {', '.join(sorted(all_categories))}") - - # Count by script type - script_stats = {} - for label, analyses in all_analyses.items(): - for script in analyses['Original'].scripts: - if script not in script_stats: - script_stats[script] = [] - script_stats[script].append(label) - - print(f"\nScript distribution:") - for script, labels in script_stats.items(): - print(f" {script}: {len(labels)} test cases") - - def _determine_script_from_name(self, char_name: str) -> str: - """Determine script from Unicode character name.""" - name_upper = char_name.upper() - - # Script indicators in Unicode names - script_indicators = { - 'LATIN': 'Latin', - 'GREEK': 'Greek', - 'CYRILLIC': 'Cyrillic', - 'CJK': 'CJK', - 'HIRAGANA': 'Japanese', - 'KATAKANA': 'Japanese', - 'HANGUL': 'Korean', - 'ARABIC': 'Arabic', - 'HEBREW': 'Hebrew', - 'COMBINING': 'Combining', - 'FULLWIDTH': 'Fullwidth', - 'SUPERSCRIPT': 'Superscript', - 'SUBSCRIPT': 'Subscript', - 'LIGATURE': 'Ligature', - 'ROMAN NUMERAL': 'Roman' - } - - for indicator, script in script_indicators.items(): - if indicator in name_upper: - return script - - # Check for specific Unicode block ranges - if not char_name or 'UNKNOWN' in char_name: - return 'Unknown' - - return 'Other' - def test_bip39_wordlist_character_categories(self): """Analyze character categories in actual BIP-39 wordlists.""" From 445d3e85002dc4ac9a4f9b846da6f1e89e648c87 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Fri, 3 Oct 2025 07:12:20 -0700 Subject: [PATCH 26/38] Correct handling of Monero mnemonic checksum Unicode normalization --- hdwallet/mnemonics/monero/mnemonic.py | 5 +++-- tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py | 1 - tests/hdwallet/mnemonics/test_mnemonics_monero.py | 5 +---- tests/hdwallet/seeds/test_seeds_electrum_v2.py | 1 - 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/hdwallet/mnemonics/monero/mnemonic.py b/hdwallet/mnemonics/monero/mnemonic.py index 0c5c02b7..b881449e 100644 --- a/hdwallet/mnemonics/monero/mnemonic.py +++ b/hdwallet/mnemonics/monero/mnemonic.py @@ -3,6 +3,7 @@ # Copyright © 2020-2024, Meheret Tesfaye Batu # Distributed under the MIT software license, see the accompanying # file COPYING or https://opensource.org/license/mit +import unicodedata from typing import ( Union, Dict, List, Optional @@ -248,7 +249,7 @@ def encode(cls, entropy: Union[str, bytes], language: str, checksum: bool = Fals if checksum: unique_prefix_length = cls.language_unique_prefix_lengths[language] - prefixes = "".join(word[:unique_prefix_length] for word in mnemonic) + prefixes = "".join(unicodedata.normalize("NFD", word)[:unique_prefix_length] for word in mnemonic) checksum_word = mnemonic[ bytes_to_integer(crc32(prefixes)) % len(mnemonic) ] @@ -289,7 +290,7 @@ def decode( if len(words) in cls.words_checksum: mnemonic: list = words[:-1] unique_prefix_length = cls.language_unique_prefix_lengths[language] - prefixes = "".join(word[:unique_prefix_length] for word in mnemonic) + prefixes = "".join(unicodedata.normalize("NFD", word)[:unique_prefix_length] for word in mnemonic) checksum_word = mnemonic[ bytes_to_integer(crc32(prefixes)) % len(mnemonic) ] diff --git a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py index 4c79b66b..3dd10a49 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_electrum_v2.py @@ -34,7 +34,6 @@ def test_electrum_v2_mnemonics(data): for mnemonic_type in __["mnemonic-types"].keys(): for language in __["mnemonic-types"][mnemonic_type].keys(): - print( f"Electrum {mnemonic_type} {language}:" ) assert ElectrumV2Mnemonic.is_valid_language(language=language) mnemonic_words=__["mnemonic-types"][mnemonic_type][language]["mnemonic"] diff --git a/tests/hdwallet/mnemonics/test_mnemonics_monero.py b/tests/hdwallet/mnemonics/test_mnemonics_monero.py index 9a81f12e..28f4df65 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_monero.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_monero.py @@ -40,9 +40,6 @@ def test_monero_mnemonics(data): assert MoneroMnemonic.is_valid_words(words=__["words"]) for language in __["languages"].keys(): - print( f"Monero {language} mnemonics:" ) - assert MoneroMnemonic.is_valid_language(language=language) - assert MoneroMnemonic.is_valid(mnemonic=__["languages"][language], language=language) mnemonic = MoneroMnemonic.from_words(words=__["words"], language=language) @@ -57,7 +54,7 @@ def test_monero_mnemonics(data): assert mnemonic.name() == __["name"] assert mnemonic.language().lower() == language - with pytest.raises(MnemonicError, match="Invalid mnemonic words"): + with pytest.raises(MnemonicError, match="Invalid Monero mnemonic words"): MoneroMnemonic( mnemonic="flower letter world foil coin poverty romance tongue taste hip cradle follow proud pluck ten improve" ) diff --git a/tests/hdwallet/seeds/test_seeds_electrum_v2.py b/tests/hdwallet/seeds/test_seeds_electrum_v2.py index 96d65b8e..92beb13e 100644 --- a/tests/hdwallet/seeds/test_seeds_electrum_v2.py +++ b/tests/hdwallet/seeds/test_seeds_electrum_v2.py @@ -24,7 +24,6 @@ def test_electrum_v2_seeds(data): language=language, mnemonic_type=mnemonic_type ) - print(f"language: {language}: {mnemonic}: {non_passphrase_seed}") assert non_passphrase_seed == data["seeds"]["Electrum-V2"][words][mnemonic_type][language]["non-passphrase-seed"] for passphrase in data["seeds"]["Electrum-V2"][words][mnemonic_type][language]["passphrases"].keys(): From 03cef2228b31e2e3b3ccf6d3ed8a30b7bf786d4b Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Fri, 3 Oct 2025 08:01:55 -0700 Subject: [PATCH 27/38] Ensure language is passed through in generate mnemonic CLI --- hdwallet/cli/generate/mnemonic.py | 14 +- hdwallet/mnemonics/imnemonic.py | 2 + tests/cli/test_cli_mnemonic.py | 13 +- tests/cli/test_cli_seed.py | 12 +- tests/test_unicode_normalization.py | 898 ---------------------------- 5 files changed, 29 insertions(+), 910 deletions(-) delete mode 100644 tests/test_unicode_normalization.py diff --git a/hdwallet/cli/generate/mnemonic.py b/hdwallet/cli/generate/mnemonic.py index 1968cafe..6b6b862c 100644 --- a/hdwallet/cli/generate/mnemonic.py +++ b/hdwallet/cli/generate/mnemonic.py @@ -98,16 +98,19 @@ def generate_mnemonic(**kwargs) -> None: if kwargs.get("mnemonic_client") == ElectrumV2Mnemonic.name(): entropy: str = ElectrumV2Mnemonic.decode( mnemonic=kwargs.get("mnemonic"), + language=kwargs.get("language"), mnemonic_type=kwargs.get("mnemonic_type") ) elif kwargs.get("mnemonic_client") == SLIP39Mnemonic.name(): entropy: str = SLIPMnemonic.decode( mnemonic=kwargs.get("mnemonic"), + language=kwargs.get("language"), passphrase=kwargs.get("mnemonic_passphrase") or "", ) else: entropy: str = MNEMONICS.mnemonic(name=kwargs.get("mnemonic_client")).decode( mnemonic=kwargs.get("mnemonic"), + language=kwargs.get("language"), ) # Now, use the recovered 'entropy' in deriving the new 'client' mnemonic. kwargs["entropy"] = entropy @@ -121,6 +124,7 @@ def generate_mnemonic(**kwargs) -> None: mnemonic_type=kwargs.get("mnemonic_type"), max_attempts=kwargs.get("max_attempts") ), + language=language, mnemonic_type=kwargs.get("mnemonic_type") ) elif kwargs.get("client") == MoneroMnemonic.name(): @@ -129,7 +133,8 @@ def generate_mnemonic(**kwargs) -> None: entropy=kwargs.get("entropy"), language=language, checksum=kwargs.get("checksum") - ) + ), + language=language, ) elif kwargs.get("client") == SLIP39Mnemonic.name(): # The supplied 'entropy', encoded w/ the SLIP-39 'language', and encrypted w/ @@ -151,7 +156,8 @@ def generate_mnemonic(**kwargs) -> None: mnemonic: IMnemonic = MNEMONICS.mnemonic(name=kwargs.get("client")).__call__( mnemonic=MNEMONICS.mnemonic(name=kwargs.get("client")).from_entropy( entropy=kwargs.get("entropy"), language=language - ) + ), + language=language, ) else: if kwargs.get("client") == ElectrumV2Mnemonic.name(): @@ -162,13 +168,15 @@ def generate_mnemonic(**kwargs) -> None: mnemonic_type=kwargs.get("mnemonic_type"), max_attempts=kwargs.get("max_attempts") ), + language=language, mnemonic_type=kwargs.get("mnemonic_type") ) else: mnemonic: IMnemonic = MNEMONICS.mnemonic(name=kwargs.get("client")).__call__( mnemonic=MNEMONICS.mnemonic(name=kwargs.get("client")).from_words( words=words, language=language - ) + ), + language=language, ) output: dict = { "client": mnemonic.name(), diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index b0092234..07081a1a 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -656,6 +656,8 @@ def find_language( if worse and matches == worse[0][1]: # There are more than one matching candidate languages -- and they are both equivalent # in quality. We cannot know (or guess) the language with any certainty. + import traceback + print("\n".join(traceback.format_stack())) raise MnemonicError(f"Ambiguous languages {', '.join(c for c, w in worse)} or {candidate} for mnemonic; specify a preferred language") return language_indices[candidate], candidate diff --git a/tests/cli/test_cli_mnemonic.py b/tests/cli/test_cli_mnemonic.py index 6b34440c..a3195093 100644 --- a/tests/cli/test_cli_mnemonic.py +++ b/tests/cli/test_cli_mnemonic.py @@ -9,7 +9,7 @@ import unicodedata from hdwallet.cli.__main__ import cli_main - +from hdwallet.mnemonics.imnemonic import unmark def check_mnemonics( cli_word, @@ -20,6 +20,10 @@ def check_mnemonics( entropy, mnemonic ): + def nfc_unmarked_set( phrase ): + nfc = unicodedata.normalize( "NFC", phrase ) + return set( (nfc, unmark( nfc )) ) + def json_parser( json_i ): json_s = ''.join( json_i ) try: @@ -46,8 +50,9 @@ def json_parser( json_i ): assert output_word["language"].lower() == language assert output_entropy["language"].lower() == language - # Mnemonics recovered will be in - assert unicodedata.normalize( "NFC", mnemonic ) == unicodedata.normalize( "NFC", output_entropy["mnemonic"] ) + # Mnemonics recovered will be in NFC form, and my be with or without Unicode Marks (for + # example, mnemonics that are ambiguous between English and French may not have accents). + assert nfc_unmarked_set( mnemonic ) & nfc_unmarked_set( output_entropy["mnemonic"] ) except Exception as exc: print( f"Failed {client} w/ {language} mnemonic: {mnemonic}: {exc}" ) @@ -111,9 +116,11 @@ def test_cli_mnemonic(data, cli_tester): entropy_args.append("--checksum") entropy_args.append(str(mnemonic_data["checksum"])) + #print(" ".join(entropy_args)) cli_entropy = cli_tester.invoke( cli_main, entropy_args ) + #print(f" --> {cli_entropy.output}") check_mnemonics( cli_word=cli_word, diff --git a/tests/cli/test_cli_seed.py b/tests/cli/test_cli_seed.py index 9ebfa4cb..f265270a 100644 --- a/tests/cli/test_cli_seed.py +++ b/tests/cli/test_cli_seed.py @@ -32,11 +32,11 @@ def test_cli_seed(data, cli_tester): else: seed = data["seeds"][client][words][mnemonic_type][language]["non-passphrase-seed"] - print(" ".join(cli_args)) + #print(" ".join(cli_args)) cli = cli_tester.invoke( cli_main, cli_args ) - print(f" --> {cli.output}") + #print(f" --> {cli.output}") output = json.loads(cli.output) assert output["client"] == client @@ -59,11 +59,11 @@ def test_cli_seed(data, cli_tester): else: seed = data["seeds"][client][words][cardano_type][language]["non-passphrase-seed"] - print(" ".join(cli_args)) + #print(" ".join(cli_args)) cli = cli_tester.invoke( cli_main, cli_args ) - print(f" --> {cli.output}") + #print(f" --> {cli.output}") output = json.loads(cli.output) assert output["client"] == client @@ -84,11 +84,11 @@ def test_cli_seed(data, cli_tester): else: seed = data["seeds"][client][words][language]["non-passphrase-seed"] - print(" ".join(cli_args)) + #print(" ".join(cli_args)) cli = cli_tester.invoke( cli_main, cli_args ) - print(f" --> {cli.output}") + #print(f" --> {cli.output}") output = json.loads(cli.output) assert output["client"] == client diff --git a/tests/test_unicode_normalization.py b/tests/test_unicode_normalization.py deleted file mode 100644 index 10f7256c..00000000 --- a/tests/test_unicode_normalization.py +++ /dev/null @@ -1,898 +0,0 @@ -#!/usr/bin/env python3 - -import os -import unicodedata -from dataclasses import dataclass -from typing import Dict, List, Tuple, Set - -import pytest - - -def remove_accents_safe(text: str) -> str: - """Remove accents from Latin/Cyrillic/Greek scripts, preserve other scripts. - - Not the correct approach; doesn't work for eg. Korean, where NFD expands unicodedata.category - "Lo" (Letter other) symbols to simply more "Lo" symbols. - - """ - text_nfd = unicodedata.normalize('NFD', text) - result = [] - for char in text_nfd: - category = unicodedata.category(char) - if category.startswith('M'): # Mark (combining) characters - if result and self._is_latin_cyrillic_greek_script(result[-1]): - continue # Skip accent marks on Latin/Cyrillic/Greek characters - result.append(char) - return ''.join(result) - -def remove_accents_safe(text: str) -> str: - """Remove accents from texts if the removed Marks leave the same number of resultant Letter glyphs. - - Normalizes all incoming text to NFC for consistency (may be raw NFD eg. from BIP-39 word lists) - - """ - text_nfc = unicodedata.normalize('NFC', text) - text_nfd = unicodedata.normalize('NFD', text_nfc) - result = [] - for char in text_nfd: - category = unicodedata.category(char) - if category.startswith('M'): # Mark (combining) characters - continue # Skip accent marks - result.append(char) - if len(result) == len(text_nfc): - return ''.join(result) - return text_nfc - - -@dataclass -class CharacterInfo: - """Information about a single Unicode character.""" - char: str - ord: int - hex: str - category: str - name: str - script: str - - -@dataclass -class UnicodeAnalysis: - """Analysis results for a Unicode text string.""" - text: str - label: str - length: int - categories: List[str] - category_counts: Dict[str, int] - scripts: Set[str] - character_details: List[CharacterInfo] - - -class TestUnicodeNormalization: - """Test Unicode normalization effects on BIP-39 mnemonic words. - - This test validates our understanding of how unicodedata.normalize works - with the four normalization forms (NFC, NFD, NFKC, NFKD) on actual - BIP-39 wordlist entries that contain Unicode characters with diacritics. - - Key normalization forms: - - NFC (Canonical Decomposition + Canonical Composition): é = é (U+00E9) - - NFD (Canonical Decomposition): é = e + ´ (U+0065 + U+0301) - - NFKC (Compatibility Decomposition + Canonical Composition): similar to NFC but more aggressive - - NFKD (Compatibility Decomposition): similar to NFD but more aggressive - - BIP-39 specifies that mnemonics should use NFC normalization. - - === KEY FINDINGS CONFIRMED BY THIS TEST === - - 1. BIP-39 wordlists use NFD (decomposed) form: - - French/Spanish wordlists store accented characters as base + combining diacritics - - Example: "café" is stored as c-a-f-e-´ (5 codepoints) not c-a-f-é (4 codepoints) - - Confirmed in: test_bip39_word_normalization_consistency() with french.txt/spanish.txt - - Evidence: "Is NFC normalized: False" for all accented words from wordlists - - 2. Normalization forms work as expected: - - NFC combines decomposed → composed (e + ´ → é, reduces length) - - NFD decomposes composed → base + combining (é → e + ´, increases length) - - Confirmed in: test_normalization_understanding() and test_manual_normalization_cases() - - 3. String length changes with normalization: - - NFD form is longer due to separate combining characters - - NFC form is shorter due to composed characters - - Confirmed in: test_bip39_word_normalization_consistency() output shows different lengths - - 4. Case is preserved through normalization: - - "café" vs "CAFÉ" maintain their case after all normalization forms - - Confirmed in: test_case_sensitivity_with_normalization() - - 5. Equivalence classes work correctly: - - Different representations (composed vs decomposed) normalize to same forms - - Confirmed in: test_normalization_equivalence_classes() - - 6. NFKC = NFC for BIP-39 words (no compatibility characters): - - All BIP-39 words pass the assertion that NFC == NFKC - - Confirmed in: test_bip39_word_normalization_consistency() assertion - """ - - @classmethod - def setup_class(cls): - """Load sample words with Unicode characters from BIP-39 wordlists.""" - base_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hdwallet/mnemonics/bip39/wordlist") - - cls.test_words = {} - - # Load French words with accents from hdwallet/mnemonics/bip39/wordlist/french.txt - # These words demonstrate KEY FINDING #1: BIP-39 uses NFD (decomposed) form - # French has ~366 words with accents stored as base letter + combining diacritics - french_path = os.path.join(base_path, "french.txt") - with open(french_path, "r", encoding="utf-8") as f: - french_words = [line.strip() for line in f if line.strip()] - cls.test_words['french'] = [ - word for word in french_words - if any(ord(c) > 127 for c in word) # Filter for non-ASCII (accented) words - ][:10] # Take first 10 for testing - - # Load Spanish words with accents from hdwallet/mnemonics/bip39/wordlist/spanish.txt - # Spanish also has ~334 words with accents, confirming NFD usage across languages - spanish_path = os.path.join(base_path, "spanish.txt") - with open(spanish_path, "r", encoding="utf-8") as f: - spanish_words = [line.strip() for line in f if line.strip()] - cls.test_words['spanish'] = [ - word for word in spanish_words - if any(ord(c) > 127 for c in word) # Filter for non-ASCII (accented) words - ][:10] # Take first 10 for testing - - # Manual test cases to understand specific normalization behaviors - # These demonstrate KEY FINDINGS #2, #4, #5 about normalization equivalence - cls.manual_test_cases = [ - # Various ways to represent "é" - demonstrates equivalence classes - "café", # é as single codepoint U+00E9 (NFC form) - "cafe\u0301", # e + combining acute accent U+0301 (NFD form) - # Various ways to represent "ñ" - demonstrates equivalence classes - "niño", # ñ as single codepoint U+00F1 (NFC form) - "nin\u0303o", # n + combining tilde U+0303 (NFD form) - # Greek letters (no combining characters, should be unchanged) - "αβγ", # Greek letters - tests that non-Latin scripts work correctly - # Mixed case - demonstrates KEY FINDING #4 about case preservation - "Café", # Mixed case with composed accent - "CAFÉ", # Upper case with composed accent - ] - - # Compatibility character test cases - these demonstrate NFKC/NFKD differences - # These characters might appear in user input but not in BIP-39 wordlists - cls.compatibility_test_cases = [ - # Roman numerals (compatibility characters) - "Ⅰ", # U+2160 ROMAN NUMERAL ONE → "I" under NFKC/NFKD - "Ⅱ", # U+2161 ROMAN NUMERAL TWO → "II" under NFKC/NFKD - "ⅰ", # U+2170 SMALL ROMAN NUMERAL ONE → "i" under NFKC/NFKD - # Circled numbers (compatibility characters) - "①", # U+2460 CIRCLED DIGIT ONE → "1" under NFKC/NFKD - "②", # U+2461 CIRCLED DIGIT TWO → "2" under NFKC/NFKD - # Fullwidth characters (compatibility characters common in Asian input) - "abc", # U+FF41, U+FF42, U+FF43 → "abc" under NFKC/NFKD - "ABC", # U+FF21, U+FF22, U+FF23 → "ABC" under NFKC/NFKD - "123", # U+FF11, U+FF12, U+FF13 → "123" under NFKC/NFKD - # Superscript/subscript (compatibility characters) - "café²", # U+00B2 SUPERSCRIPT TWO → "café2" under NFKC/NFKD - "H₂O", # H + U+2082 SUBSCRIPT TWO + O → "H2O" under NFKC/NFKD - # Ligatures (compatibility characters) - "file", # U+FB01 LATIN SMALL LIGATURE FI → "file" under NFKC/NFKD - "ff", # U+FB00 LATIN SMALL LIGATURE FF → "ff" under NFKC/NFKD - # Mixed compatibility with BIP-39 relevant words - "café", # Fullwidth + normal é → "café" under NFKC/NFKD - ] - - def analyze_unicode_string(self, text: str) -> Dict[str, any]: - """Analyze a Unicode string and return detailed information.""" - return { - 'original': text, - 'length': len(text), - 'codepoints': [hex(ord(c)) for c in text], - 'char_names': [unicodedata.name(c, f'UNKNOWN-{ord(c):04X}') for c in text], - 'nfc': unicodedata.normalize('NFC', text), - 'nfd': unicodedata.normalize('NFD', text), - 'nfkc': unicodedata.normalize('NFKC', text), - 'nfkd': unicodedata.normalize('NFKD', text), - } - - def test_normalization_understanding(self): - """Test basic understanding of Unicode normalization forms.""" - # Test composed vs decomposed é - composed_e = "é" # U+00E9 - decomposed_e = "e\u0301" # U+0065 + U+0301 - - # NFC should give us the composed form - assert unicodedata.normalize('NFC', composed_e) == composed_e - assert unicodedata.normalize('NFC', decomposed_e) == composed_e - - # NFD should give us the decomposed form - assert unicodedata.normalize('NFD', composed_e) == decomposed_e - assert unicodedata.normalize('NFD', decomposed_e) == decomposed_e - - # They should be different in their raw forms but equal after normalization - assert composed_e != decomposed_e # Different byte representations - assert unicodedata.normalize('NFC', composed_e) == unicodedata.normalize('NFC', decomposed_e) - - def test_bip39_word_normalization_consistency(self): - """Test that BIP-39 words are consistently normalized. - - CONFIRMS KEY FINDINGS #1, #3, #6: - - Shows BIP-39 wordlists use NFD form ("Is NFC normalized: False") - - Demonstrates length differences between NFC/NFD forms - - Verifies NFC == NFKC (no compatibility characters in BIP-39) - - Tests actual words from french.txt and spanish.txt wordlists - """ - for language, words in self.test_words.items(): - print(f"\n=== Testing {language} words ===") - - for word in words: - analysis = self.analyze_unicode_string(word) - - print(f"\nWord: {word}") - print(f" Codepoints: {analysis['codepoints']}") - print(f" NFC: '{analysis['nfc']}' (len={len(analysis['nfc'])})") - print(f" NFD: '{analysis['nfd']}' (len={len(analysis['nfd'])})") - print(f" NFKC: '{analysis['nfkc']}' (len={len(analysis['nfkc'])})") - print(f" NFKD: '{analysis['nfkd']}' (len={len(analysis['nfkd'])})") - - # KEY FINDING #1: BIP-39 words are stored in NFD (decomposed) form - # This will show "False" for all accented words, proving they use NFD - is_nfc_normalized = (word == analysis['nfc']) - print(f" Is NFC normalized: {is_nfc_normalized}") - - # All forms should produce valid strings - assert isinstance(analysis['nfc'], str) - assert isinstance(analysis['nfd'], str) - assert isinstance(analysis['nfkc'], str) - assert isinstance(analysis['nfkd'], str) - - # KEY FINDING #6: NFC == NFKC for BIP-39 words (no compatibility characters) - assert analysis['nfc'] == analysis['nfkc'], f"NFC != NFKC for {word}" - - def test_manual_normalization_cases(self): - """Test specific normalization cases to understand behavior. - - CONFIRMS KEY FINDINGS #2, #5: - - Shows how NFC combines decomposed characters (shorter length) - - Shows how NFD decomposes composed characters (longer length) - - Demonstrates equivalence classes: different representations normalize to same result - - Tests both composed (é) and decomposed (e + ´) input forms - """ - print("\n=== Manual Test Cases ===") - - for test_case in self.manual_test_cases: - analysis = self.analyze_unicode_string(test_case) - - print(f"\nTest case: '{test_case}'") - print(f" Original codepoints: {analysis['codepoints']}") - print(f" Character names: {analysis['char_names']}") - - # Show all normalization forms - demonstrates KEY FINDING #2 - for form in ['nfc', 'nfd', 'nfkc', 'nfkd']: - normalized = analysis[form] - norm_codepoints = [hex(ord(c)) for c in normalized] - print(f" {form.upper():4}: '{normalized}' -> {norm_codepoints}") - - # Verify normalization is idempotent (applying twice gives same result) - double_normalized = unicodedata.normalize(form.upper(), normalized) - assert double_normalized == normalized, f"Double {form.upper()} normalization changed result" - - def test_case_sensitivity_with_normalization(self): - """Test how case affects normalization. - - CONFIRMS KEY FINDING #4: - - Case is preserved through all normalization forms - - "café" stays lowercase, "CAFÉ" stays uppercase - - Normalization affects accents but not case - """ - test_cases = [ - ("café", "CAFÉ"), # French word with acute accent - ("niño", "NIÑO"), # Spanish word with tilde - ] - - print("\n=== Case Sensitivity Tests ===") - - for lower, upper in test_cases: - print(f"\nTesting: '{lower}' vs '{upper}'") - - for form in ['NFC', 'NFD', 'NFKC', 'NFKD']: - lower_norm = unicodedata.normalize(form, lower) - upper_norm = unicodedata.normalize(form, upper) - - print(f" {form}: '{lower_norm}' vs '{upper_norm}'") - - # Case should be preserved through normalization - assert lower_norm.lower() == lower_norm - assert upper_norm.upper() == upper_norm - - # Normalization should not change case - assert lower_norm != upper_norm - - def test_normalization_equivalence_classes(self): - """Test that different representations normalize to the same result. - - CONFIRMS KEY FINDING #5: - - Different Unicode representations of same character normalize to same forms - - Composed "é" and decomposed "e + ´" both normalize to same NFC and NFD results - - This is critical for BIP-39 mnemonic validation across different input methods - """ - equivalence_classes = [ - # Different ways to represent é (composed vs decomposed) - ["é", "e\u0301"], # U+00E9 vs U+0065+U+0301 - # Different ways to represent ñ (composed vs decomposed) - ["ñ", "n\u0303"], # U+00F1 vs U+006E+U+0303 - ] - - print("\n=== Equivalence Classes ===") - - for equiv_class in equivalence_classes: - print(f"\nTesting equivalence class: {[repr(s) for s in equiv_class]}") - - # All should normalize to the same NFC form - nfc_results = [unicodedata.normalize('NFC', s) for s in equiv_class] - assert len(set(nfc_results)) == 1, f"NFC normalization not consistent: {nfc_results}" - - # All should normalize to the same NFD form - nfd_results = [unicodedata.normalize('NFD', s) for s in equiv_class] - assert len(set(nfd_results)) == 1, f"NFD normalization not consistent: {nfd_results}" - - print(f" NFC result: {repr(nfc_results[0])}") - print(f" NFD result: {repr(nfd_results[0])}") - - def test_accent_removal_for_fallback_matching(self): - """Test accent removal for fallback matching while preserving non-Latin scripts. - - This test creates a function to remove accents from Latin, Cyrillic, and Greek - characters while leaving Chinese, Japanese, Korean, and other scripts unchanged. - This could be useful for fuzzy matching when exact Unicode matches fail. - """ - - - print("\n=== Accent Removal Tests ===") - - # Test cases for different scripts - test_cases = [ - # Latin script - should have accents removed - { - 'input': 'café', - 'expected': 'cafe', - 'script': 'Latin', - 'should_change': True - }, - { - 'input': 'niño', - 'expected': 'nino', - 'script': 'Latin', - 'should_change': True - }, - { - 'input': 'académie', - 'expected': 'academie', - 'script': 'Latin', - 'should_change': True - }, - { - 'input': 'algèbre', - 'expected': 'algebre', - 'script': 'Latin', - 'should_change': True - }, - # Greek script - should have accents removed (if any) - { - 'input': 'αβγ', # Greek letters without accents - 'expected': 'αβγ', - 'script': 'Greek', - 'should_change': False - }, - # Cyrillic script - should have accents removed (if any) - { - 'input': 'абв', # Cyrillic letters - 'expected': 'абв', - 'script': 'Cyrillic', - 'should_change': False - }, - # Chinese - should be left unchanged - { - 'input': '中文', - 'expected': '中文', - 'script': 'Chinese', - 'should_change': False - }, - # Japanese Hiragana - should be left unchanged - { - 'input': 'あいう', - 'expected': 'あいう', - 'script': 'Japanese Hiragana', - 'should_change': False - }, - # Japanese Katakana - should be left unchanged - { - 'input': 'アイウ', - 'expected': 'アイウ', - 'script': 'Japanese Katakana', - 'should_change': False - }, - # Korean - should be left unchanged - { - 'input': '한글', - 'expected': '한글', - 'script': 'Korean', - 'should_change': False - }, - # Arabic - should be left unchanged - { - 'input': 'العربية', - 'expected': 'العربية', - 'script': 'Arabic', - 'should_change': False - }, - # Hebrew - should be left unchanged - { - 'input': 'עברית', - 'expected': 'עברית', - 'script': 'Hebrew', - 'should_change': False - }, - # Mixed script - only Latin parts should be modified - { - 'input': 'café中文', - 'expected': 'cafe中文', - 'script': 'Mixed Latin+Chinese', - 'should_change': True - } - ] - - for case in test_cases: - input_text = case['input'] - expected = case['expected'] - script = case['script'] - should_change = case['should_change'] - - result = remove_accents_safe(input_text) - - print(f"\nTesting {script}: '{input_text}' -> '{result}'") - print(f" Expected: '{expected}'") - print(f" Should change: {should_change}") - print(f" Did change: {result != input_text}") - - assert result == expected, f"Failed for {script}: '{input_text}' -> '{result}', expected '{expected}'" - - # Verify our expectation about whether it should change - if should_change: - assert result != input_text, f"Expected {script} text to change but it didn't: '{input_text}'" - else: - assert result == input_text, f"Expected {script} text to remain unchanged but it changed: '{input_text}' -> '{result}'" - - def _is_latin_cyrillic_greek_script(self, char: str) -> bool: - """Check if character belongs to Latin, Cyrillic, or Greek script.""" - if not char: - return False - - code_point = ord(char) - - # Latin script ranges (Basic Latin, Latin-1 Supplement, Latin Extended A/B, etc.) - latin_ranges = [ - (0x0041, 0x007A), # Basic Latin A-Z, a-z - (0x00C0, 0x00FF), # Latin-1 Supplement (À-ÿ) - (0x0100, 0x017F), # Latin Extended-A - (0x0180, 0x024F), # Latin Extended-B - (0x1E00, 0x1EFF), # Latin Extended Additional - ] - - # Greek script ranges - greek_ranges = [ - (0x0370, 0x03FF), # Greek and Coptic - (0x1F00, 0x1FFF), # Greek Extended - ] - - # Cyrillic script ranges - cyrillic_ranges = [ - (0x0400, 0x04FF), # Cyrillic - (0x0500, 0x052F), # Cyrillic Supplement - ] - - # Check if character falls in any of these ranges - all_ranges = latin_ranges + greek_ranges + cyrillic_ranges - - for start, end in all_ranges: - if start <= code_point <= end: - return True - - return False - - def test_accent_removal_with_bip39_words(self): - """Test accent removal specifically with BIP-39 words from accented languages.""" - - print("\n=== BIP-39 Word Accent Removal Tests ===") - - # Test with actual French BIP-39 words (using our test words from setup_class) - if hasattr(self, 'test_words') and 'french' in self.test_words: - for word in self.test_words['french'][:5]: # Test first 5 French words - deaccented = remove_accents_safe(word) - print(f" French: '{word}' -> '{deaccented}'") - # Verify that accents were actually removed (should be shorter or different) - if any(ord(c) > 127 for c in word): # Contains non-ASCII (accented chars) - assert word != deaccented, f"Expected accent removal for '{word}'" - - # Test with actual Spanish BIP-39 words - if hasattr(self, 'test_words') and 'spanish' in self.test_words: - for word in self.test_words['spanish'][:5]: # Test first 5 Spanish words - deaccented = remove_accents_safe(word) - print(f" Spanish: '{word}' -> '{deaccented}'") - # Verify that accents were actually removed - if any(ord(c) > 127 for c in word): # Contains non-ASCII (accented chars) - assert word != deaccented, f"Expected accent removal for '{word}'" - - # Test with known examples - test_examples = [ - ('académie', 'academie'), - ('acquérir', 'acquerir'), - ('algèbre', 'algebre'), - ('ábaco', 'abaco'), - ('acción', 'accion'), - ('niño', 'nino') - ] - - for original, expected in test_examples: - deaccented = remove_accents_safe(original) - print(f" '{original}' -> '{deaccented}'") - assert deaccented == expected, f"Expected '{expected}', got '{deaccented}'" - - # Test that non-Latin scripts are unchanged, unless removing the Marks leaves the same - # number of Letter category glyphs/symbols. - non_latin_examples = [ - '中文', # Chinese - 'あいう', # Japanese Hiragana - 'アイウ', # Japanese Katakana - '한글', # Korean - 'العربية', # Arabic - 'עברית' # Hebrew - ] - - for word in non_latin_examples: - deaccented = remove_accents_safe(word) - print(f" Non-Latin '{word}' -> '{deaccented}' (should be unchanged)") - assert deaccented == word, f"Non-Latin script should not change: '{word}' -> '{deaccented}'" - - def test_bip39_wordlist_character_categories(self): - """Analyze character categories in actual BIP-39 wordlists.""" - - def analyze_wordlist_categories(language: str, max_words: int = 10): - """Analyze character categories in a BIP-39 wordlist.""" - print(f"\n=== BIP-39 Wordlist Analysis: {language.capitalize()} ===") - - # Get words from the wordlist using our test setup - if not hasattr(self, 'test_words') or language not in self.test_words: - print(f"No test words available for {language}") - return - - words = self.test_words[language][:max_words] - - category_summary = {} - script_summary = set() - - for word in words: - print(f"\nWord: '{word}'") - - # Analyze original word - for i, char in enumerate(word): - category = unicodedata.category(char) - name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') - script = self._determine_script_from_name(name) - - print(f" [{i}] '{char}' U+{ord(char):04X} {category} - {name}") - - category_summary[category] = category_summary.get(category, 0) + 1 - script_summary.add(script) - - # Show NFD decomposition - nfd_word = unicodedata.normalize('NFD', word) - if nfd_word != word: - print(f" NFD decomposition: '{nfd_word}' (length {len(nfd_word)})") - for i, char in enumerate(nfd_word): - if i >= len(word) or char != word[i]: - category = unicodedata.category(char) - name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') - print(f" [{i}] '{char}' U+{ord(char):04X} {category} - {name}") - - print(f"\nCategory summary for {language}:") - for category, count in sorted(category_summary.items()): - print(f" {category}: {count}") - - print(f"Scripts found: {', '.join(sorted(script_summary))}") - - return category_summary, script_summary - - # Analyze available wordlists - if hasattr(self, 'test_words'): - for language in self.test_words.keys(): - analyze_wordlist_categories(language, max_words=5) - - # Also test some known challenging cases - print(f"\n=== Manual BIP-39 Word Examples ===") - - challenging_words = [ - ('académie', 'French with acute accent'), - ('algèbre', 'French with grave accent'), - ('ábaco', 'Spanish with acute accent'), - ('niño', 'Spanish with tilde'), - ('acción', 'Spanish with acute accent') - ] - - for word, description in challenging_words: - print(f"\n{description}: '{word}'") - - # Show character-by-character breakdown - for i, char in enumerate(word): - category = unicodedata.category(char) - name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') - print(f" [{i}] '{char}' U+{ord(char):04X} {category} - {name}") - - # Show NFD breakdown - nfd_word = unicodedata.normalize('NFD', word) - if nfd_word != word: - print(f" NFD: '{nfd_word}'") - for i, char in enumerate(nfd_word): - category = unicodedata.category(char) - name = unicodedata.name(char, f'UNKNOWN-{ord(char):04X}') - combining = " (COMBINING)" if category.startswith('M') else "" - print(f" [{i}] '{char}' U+{ord(char):04X} {category}{combining} - {name}") - - def test_normalization_preserves_meaning(self): - """Test that normalization preserves the semantic meaning of words. - - CONFIRMS that normalization is reversible and consistent: - - Converting NFD→NFC→NFD produces the original NFD form - - Converting NFC→NFD→NFC produces the original NFC form - - Double normalization is idempotent (same result) - - Critical for ensuring BIP-39 mnemonics work regardless of input normalization - """ - for language, words in self.test_words.items(): - for word in words: - nfc_word = unicodedata.normalize('NFC', word) - nfd_word = unicodedata.normalize('NFD', word) - - # While byte representation may differ, they should represent the same word - # This is more of a documentation test - we can't programmatically verify - # semantic equivalence, but we can verify they normalize consistently - - # Double normalization should be idempotent - assert unicodedata.normalize('NFC', nfc_word) == nfc_word - assert unicodedata.normalize('NFD', nfd_word) == nfd_word - - # Converting between forms should be consistent (reversible) - assert unicodedata.normalize('NFC', nfd_word) == nfc_word - assert unicodedata.normalize('NFD', nfc_word) == nfd_word - - def test_compatibility_character_normalization(self): - """Test NFKC/NFKD normalization effects on compatibility characters. - - CRITICAL ANALYSIS FOR BIP-39 PROCESSING: - This test determines whether BIP-39 implementations need to handle - compatibility normalization when comparing user input to wordlist words. - - Compatibility characters that might appear in user input: - - Fullwidth characters from Asian input methods (abc → abc) - - Roman numerals (Ⅰ → I) - - Ligatures (fi → fi) - - Circled numbers (① → 1) - - Super/subscripts (² → 2) - - Key questions answered: - 1. Do NFKC/NFKD change compatibility chars differently than NFC/NFD? - 2. Could user input contain compatibility chars that map to BIP-39 words? - 3. Should BIP-39 validation use NFKC instead of NFC for robustness? - """ - print("\n=== Compatibility Character Analysis ===") - - compatibility_transformations = [] - - for test_case in self.compatibility_test_cases: - analysis = self.analyze_unicode_string(test_case) - - print(f"\nCompatibility test: '{test_case}'") - print(f" Original codepoints: {analysis['codepoints']}") - print(f" Character names: {analysis['char_names']}") - - # Compare all normalization forms - nfc_result = analysis['nfc'] - nfd_result = analysis['nfd'] - nfkc_result = analysis['nfkc'] - nfkd_result = analysis['nfkd'] - - print(f" NFC : '{nfc_result}' -> {[hex(ord(c)) for c in nfc_result]}") - print(f" NFD : '{nfd_result}' -> {[hex(ord(c)) for c in nfd_result]}") - print(f" NFKC: '{nfkc_result}' -> {[hex(ord(c)) for c in nfkc_result]}") - print(f" NFKD: '{nfkd_result}' -> {[hex(ord(c)) for c in nfkd_result]}") - - # Check if NFKC/NFKD produce different results than NFC/NFD - canonical_vs_compatibility = (nfc_result != nfkc_result) or (nfd_result != nfkd_result) - - if canonical_vs_compatibility: - transformation = { - 'original': test_case, - 'nfc': nfc_result, - 'nfkc': nfkc_result, - 'transformation_type': 'compatibility_normalization' - } - compatibility_transformations.append(transformation) - print(f" *** COMPATIBILITY TRANSFORMATION: '{test_case}' -> '{nfkc_result}' ***") - else: - print(f" No compatibility transformation (NFC == NFKC)") - - # Store results for analysis - self.compatibility_transformations = compatibility_transformations - - # Verify we found some compatibility transformations - assert len(compatibility_transformations) > 0, "Expected to find compatibility character transformations" - - print(f"\nFound {len(compatibility_transformations)} compatibility transformations") - - def test_bip39_wordlist_compatibility_analysis(self): - """Analyze whether BIP-39 wordlists contain any compatibility characters. - - DETERMINES: Do actual BIP-39 wordlists use compatibility characters? - If not, then compatibility normalization is only needed for user input processing. - """ - print("\n=== BIP-39 Wordlist Compatibility Analysis ===") - - compatibility_found_in_wordlists = [] - - for language, words in self.test_words.items(): - print(f"\nAnalyzing {language} wordlist...") - - for word in words: - nfc_form = unicodedata.normalize('NFC', word) - nfkc_form = unicodedata.normalize('NFKC', word) - - if nfc_form != nfkc_form: - compatibility_found_in_wordlists.append({ - 'language': language, - 'word': word, - 'nfc': nfc_form, - 'nfkc': nfkc_form - }) - print(f" COMPATIBILITY CHARACTER FOUND: '{word}' -> '{nfkc_form}'") - - if compatibility_found_in_wordlists: - print(f"\nWARNING: Found {len(compatibility_found_in_wordlists)} compatibility characters in wordlists!") - for item in compatibility_found_in_wordlists: - print(f" {item['language']}: '{item['word']}' -> '{item['nfkc']}'") - else: - print("\nRESULT: No compatibility characters found in BIP-39 wordlists") - print("This confirms BIP-39 wordlists use only canonical Unicode forms") - - def test_user_input_compatibility_scenarios(self): - """Test realistic user input scenarios with compatibility characters. - - PRACTICAL ANALYSIS: What happens when users enter compatibility characters - that could map to valid BIP-39 words after NFKC normalization? - """ - print("\n=== User Input Compatibility Scenarios ===") - - # Simulate user input scenarios that might contain compatibility chars - user_input_scenarios = [ - # Fullwidth input (common with Asian keyboards) - { - 'input': 'académie', # Fullwidth 'académie' - 'expected_word': 'académie', - 'scenario': 'Asian keyboard fullwidth input' - }, - # Mixed fullwidth/normal - { - 'input': 'action', # Fullwidth 'a' + normal 'ction' - 'expected_word': 'action', - 'scenario': 'Mixed fullwidth/normal input' - }, - # Ligature input - { - 'input': 'profit', # Contains ligature fi - 'expected_word': 'profit', - 'scenario': 'Ligature character input' - }, - ] - - bip39_validation_recommendations = [] - - for scenario in user_input_scenarios: - user_input = scenario['input'] - expected = scenario['expected_word'] - description = scenario['scenario'] - - print(f"\nScenario: {description}") - print(f" User input: '{user_input}'") - print(f" Expected word: '{expected}'") - - # Test different normalization approaches - nfc_result = unicodedata.normalize('NFC', user_input) - nfkc_result = unicodedata.normalize('NFKC', user_input) - - print(f" NFC normalization: '{nfc_result}'") - print(f" NFKC normalization: '{nfkc_result}'") - - # Check if NFKC helps match the expected word - nfc_matches_expected = (nfc_result == expected) - nfkc_matches_expected = (nfkc_result == expected) - - print(f" NFC matches expected: {nfc_matches_expected}") - print(f" NFKC matches expected: {nfkc_matches_expected}") - - if not nfc_matches_expected and nfkc_matches_expected: - recommendation = f"NFKC normalization needed for: {description}" - bip39_validation_recommendations.append(recommendation) - print(f" *** RECOMMENDATION: Use NFKC normalization for this scenario ***") - elif nfc_matches_expected: - print(f" NFC normalization sufficient") - - # Store recommendations for final analysis - self.bip39_validation_recommendations = bip39_validation_recommendations - - if bip39_validation_recommendations: - print(f"\n=== BIP-39 VALIDATION RECOMMENDATIONS ===") - for rec in bip39_validation_recommendations: - print(f" • {rec}") - else: - print(f"\nNo special compatibility handling needed for tested scenarios") - - def test_compatibility_normalization_security_implications(self): - """Analyze security implications of compatibility normalization in BIP-39. - - SECURITY ANALYSIS: Could compatibility normalization create security issues? - - Homograph attacks (different characters that look similar) - - Unexpected transformations that change meaning - - Compatibility chars that map to multiple possible words - """ - print("\n=== Compatibility Normalization Security Analysis ===") - - # Test potential homograph/confusable scenarios - potentially_confusing_cases = [ - # Roman numeral vs Latin letters - { - 'char1': 'I', # Latin capital I - 'char2': 'Ⅰ', # Roman numeral I - 'concern': 'Roman numeral I vs Latin I' - }, - # Fullwidth vs normal - { - 'char1': 'a', # Normal Latin a - 'char2': 'a', # Fullwidth Latin a - 'concern': 'Fullwidth vs normal Latin letters' - }, - # Ligature vs separate chars - { - 'char1': 'fi', # Separate f + i - 'char2': 'fi', # Ligature fi - 'concern': 'Ligature fi vs separate f+i' - } - ] - - security_warnings = [] - - for case in potentially_confusing_cases: - char1 = case['char1'] - char2 = case['char2'] - concern = case['concern'] - - # Test if they normalize to the same thing under NFKC - nfkc1 = unicodedata.normalize('NFKC', char1) - nfkc2 = unicodedata.normalize('NFKC', char2) - - print(f"\nTesting: {concern}") - print(f" '{char1}' NFKC-> '{nfkc1}'") - print(f" '{char2}' NFKC-> '{nfkc2}'") - - if nfkc1 == nfkc2: - warning = f"SECURITY CONCERN: {concern} normalize to same result under NFKC" - security_warnings.append(warning) - print(f" *** {warning} ***") - else: - print(f" No normalization collision") - - # Final security assessment - print(f"\n=== SECURITY ASSESSMENT ===") - if security_warnings: - print(f"Found {len(security_warnings)} potential security concerns:") - for warning in security_warnings: - print(f" ⚠️ {warning}") - print(f"\nRECOMMENDATION: Carefully validate NFKC normalization in BIP-39 implementations") - else: - print(f"No obvious security concerns found with NFKC normalization") From 2929e8142fcbf369eddcabe2f3a72fc28b8bfa58 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sat, 4 Oct 2025 06:43:43 -0700 Subject: [PATCH 28/38] Clean up some test output --- hdwallet/mnemonics/imnemonic.py | 2 -- tests/test_bip39_cross_language.py | 36 +++++++++--------------------- 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 07081a1a..b0092234 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -656,8 +656,6 @@ def find_language( if worse and matches == worse[0][1]: # There are more than one matching candidate languages -- and they are both equivalent # in quality. We cannot know (or guess) the language with any certainty. - import traceback - print("\n".join(traceback.format_stack())) raise MnemonicError(f"Ambiguous languages {', '.join(c for c, w in worse)} or {candidate} for mnemonic; specify a preferred language") return language_indices[candidate], candidate diff --git a/tests/test_bip39_cross_language.py b/tests/test_bip39_cross_language.py index 81236d4c..55eeb617 100644 --- a/tests/test_bip39_cross_language.py +++ b/tests/test_bip39_cross_language.py @@ -134,8 +134,6 @@ def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_att except ChecksumError as exc: # Skip invalid mnemonics (e.g., checksum failures) - #import traceback - #print( f"Failed to decode: {traceback.format_exc()}" ) continue success_rate = len(successful_both_languages) / total_attempts @@ -288,10 +286,6 @@ def test_trie_functionality(self): terminal, stem, current = trie.search("abandon", complete=True) assert terminal and stem == "abandon" and current.value == 0, "Full word should still work with complete=True" - print("✓ All default Trie functionality tests passed!") - print(f"✓ Tested with {len(test_words)} words") - print("✓ Verified exact lookups, prefix detection, and unambiguous abbreviation completion") - def scan_value( w_n ): return w_n[0], w_n[1].value @@ -423,10 +417,6 @@ class CustomTrieNode(TrieNode): terminal, stem, current = custom_trie.search("testin", complete=True) assert terminal and stem == "testing" and current.value == 99 # Unambiguous: completes to "testing" - print("✓ Custom TrieNode marker functionality verified!") - print("✓ Design pattern allows for derived TrieNode classes with custom EMPTY values") - - test_indices = WordIndices(test_words) assert str(test_indices) == """\ a b a n d o n == 0 @@ -470,11 +460,11 @@ def test_ambiguous_languages(self): word_indices, detected_language = BIP39Mnemonic.find_language(test_mnemonic) # If this succeeds, it means one language had a higher quality score than others # This is valid behavior - not all common word combinations are equally ambiguous - print(f"Mnemonic resolved to {detected_language} (quality was decisive)") + #print(f"Mnemonic resolved to {detected_language} (quality was decisive)") except MnemonicError as e: # This is the expected behavior for truly ambiguous mnemonics - assert "Ambiguous languages" in str(e), f"Expected ambiguity error, got: {e}" - assert "specify a preferred language" in str(e), f"Expected preference suggestion, got: {e}" + #assert "Ambiguous languages" in str(e), f"Expected ambiguity error, got: {e}" + #assert "specify a preferred language" in str(e), f"Expected preference suggestion, got: {e}" print(f"✓ Correctly detected ambiguous mnemonic: {e}") # Test 2: Verify that specifying a preferred language resolves the ambiguity @@ -486,9 +476,10 @@ def test_ambiguous_languages(self): test_mnemonic, language=language ) resolved_languages.append(detected_language) - print(f"✓ Successfully resolved with preferred language '{language}' -> {detected_language}") + #print(f"✓ Successfully resolved with preferred language '{language}' -> {detected_language}") except MnemonicError as e: print(f"Failed to resolve with language '{language}': {e}") + raise # At least one language should successfully resolve the mnemonic assert len(resolved_languages) > 0, "No language could resolve the test mnemonic" @@ -500,26 +491,25 @@ def test_ambiguous_languages(self): try: word_indices, detected_language = BIP39Mnemonic.find_language(alt_test_mnemonic) - print(f"Alternative mnemonic resolved to {detected_language}") + #print(f"Alternative mnemonic resolved to {detected_language}") except MnemonicError as e: if "Ambiguous languages" in str(e): - print(f"✓ Alternative mnemonic also correctly detected as ambiguous: {e}") + #print(f"✓ Alternative mnemonic also correctly detected as ambiguous: {e}") # Test that preferred language resolves it word_indices, detected_language = BIP39Mnemonic.find_language( alt_test_mnemonic, language='english' ) - print(f"✓ Alternative mnemonic resolved with preferred language: {detected_language}") + #print(f"✓ Alternative mnemonic resolved with preferred language: {detected_language}") else: raise # Re-raise unexpected errors # Test 4: Verify behavior with abbreviations if common abbreviations exist if len(self.common_abbrevs) >= 12: abbrev_mnemonic = list(self.common_abbrevs)[:12] - print(f"Testing with common abbreviations: {abbrev_mnemonic[:5]}...") try: word_indices, detected_language = BIP39Mnemonic.find_language(abbrev_mnemonic) - print(f"Abbreviation mnemonic resolved to {detected_language}") + #print(f"Abbreviation mnemonic resolved to {detected_language}") except MnemonicError as e: if "Ambiguous languages" in str(e): print(f"✓ Abbreviation mnemonic correctly detected as ambiguous") @@ -527,14 +517,10 @@ def test_ambiguous_languages(self): word_indices, detected_language = BIP39Mnemonic.find_language( abbrev_mnemonic, language='english' ) - print(f"✓ Abbreviation mnemonic resolved with preferred language: {detected_language}") + #print(f"✓ Abbreviation mnemonic resolved with preferred language: {detected_language}") else: raise # Re-raise unexpected errors - print("✓ Ambiguous language detection tests completed successfully") - print(f"✓ Tested with {len(test_mnemonic)} common words") - print("✓ Verified ambiguity detection and preferred language resolution") - def test_bip39_korean(): # Confirm that UTF-8 Mark handling works in other languages (particularly Korean) @@ -567,7 +553,7 @@ def test_bip39_korean(): 각오 각자""" korean_trie_20 = "\n".join(korean_indices._trie.dump_lines()[:20]) - print(korean_trie_20) + #print(korean_trie_20) assert korean_trie_20 == """\ 가 격 == 0 끔 == 1 From eb964fd7f994f98126227ffb8ffad4dac9eaadd4 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sat, 4 Oct 2025 07:45:41 -0700 Subject: [PATCH 29/38] Begin work toward mypy type checking --- Makefile | 7 +++++-- hdwallet/libs/base58.py | 6 ++---- hdwallet/mnemonics/imnemonic.py | 26 ++++++++++++-------------- mypy.ini | 22 ++++++++++++++++++++++ requirements/tests.txt | 1 + 5 files changed, 42 insertions(+), 20 deletions(-) create mode 100644 mypy.ini diff --git a/Makefile b/Makefile index a0b2161d..772265c2 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ export PYTHON ?= $(shell python3 --version >/dev/null 2>&1 && echo python3 || e PYTHON_V = $(shell $(PYTHON) -c "import sys; print('-'.join((('venv' if sys.prefix != sys.base_prefix else next(iter(filter(None,sys.base_prefix.split('/'))))),sys.platform,sys.implementation.cache_tag)))" 2>/dev/null ) export PYTEST ?= $(PYTHON) -m pytest -export PYTEST_OPTS ?= -vv --capture=no # --mypy +export PYTEST_OPTS ?= -vv --capture=no VERSION = $(shell $(PYTHON) -c "exec(open('hdwallet/info.py').read()); print(__version__[1:])" ) @@ -31,7 +31,7 @@ export NIX_OPTS ?= help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: help wheel install test analyze venv Makefile FORCE +.PHONY: help wheel install test analyze types venv Makefile FORCE wheel: $(WHEEL) @@ -60,6 +60,9 @@ analyze: --ignore=W503,W504,E201,E202,E223,E226 \ hdwallet +types: + mypy . + # # Nix and VirtualEnv build, install and activate # diff --git a/hdwallet/libs/base58.py b/hdwallet/libs/base58.py index f0270085..07b2a228 100644 --- a/hdwallet/libs/base58.py +++ b/hdwallet/libs/base58.py @@ -4,8 +4,6 @@ from Crypto.Hash import keccak from typing import List -import six - __base58_alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" @@ -36,9 +34,9 @@ def string_to_int(data): def ensure_string(data): - if isinstance(data, six.binary_type): + if isinstance(data, bytes): return data.decode("utf-8") - elif not isinstance(data, six.string_types): + elif not isinstance(data, str): raise ValueError("Invalid value for string") return data diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index b0092234..ba19a80c 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -37,11 +37,13 @@ def __init__(self, message, word: str, options: Set[str]): class TrieNode: - # Associates a value with a node in a trie. - # - # The EMPTY marker indicates that a word ending in this TrieNode was not inserted into the True; - # replace with something that will never be provided as a word's 'value', preferably something - # "Falsey". An insert defaults to PRESENT, preferably something "Truthy". + """ + Associates a value with a node in a trie. + + The EMPTY marker indicates that a word ending in this TrieNode was not inserted into the True; + replace with something that will never be provided as a word's 'value', preferably something + "Falsey". An insert defaults to PRESENT, preferably something "Truthy". + """ EMPTY = None PRESENT = True def __init__(self): @@ -52,9 +54,6 @@ def __init__(self): class Trie: def __init__(self, root=None): - """ - Initialize your data structure here. - """ self.root = root if root is not None else TrieNode() def insert(self, word: str, value: Optional[Any] = None) -> None: @@ -68,7 +67,7 @@ def insert(self, word: str, value: Optional[Any] = None) -> None: f"Attempt to re-insert {word!r}; already present with value {current.value!r}" current.value = current.PRESENT if value is None else value - def find(self, word: str, current: Optional[TrieNode] = None) -> Generator[Tuple[str, Optional[TrieNode]], None, None]: + def find(self, word: str, current: Optional[TrieNode] = None) -> Generator[Tuple[bool, str, Optional[TrieNode]], None, None]: """Finds all the TrieNode that match the word, optionally from the provided 'current' node. If the word isn't in the current Trie, terminates by producing None for the TrieNode. @@ -85,7 +84,7 @@ def find(self, word: str, current: Optional[TrieNode] = None) -> Generator[Tuple break yield current.value is not current.EMPTY, letter, current - def complete(self, current: TrieNode) -> Generator[Tuple[str, TrieNode], None, None]: + def complete(self, current: TrieNode) -> Generator[Tuple[bool, str, TrieNode], None, None]: """Generate (, key, node) tuples along an unambiguous path starting from after the current TrieNode, until the next terminal TrieNode is encountered. @@ -104,7 +103,7 @@ def complete(self, current: TrieNode) -> Generator[Tuple[str, TrieNode], None, N terminal = current.value is not current.EMPTY yield terminal, key, current - def search(self, word: str, current: Optional[TrieNode] = None, complete: bool = False) -> Tuple[str, Optional[TrieNode]]: + def search(self, word: str, current: Optional[TrieNode] = None, complete: bool = False) -> Tuple[bool, str, Optional[TrieNode]]: """Returns the matched stem, and associated TrieNode if the word is in the trie (otherwise None) If 'complete' and 'word' is an unambiguous abbreviation of some word with a non-EMPTY value, @@ -208,8 +207,9 @@ def __str__(self): def unmark( word_composed: str ) -> str: """This word may contain composite characters with accents like "é" that decompose "e" + "'". + Most mnemonic encodings require that mnemonic words without accents match the accented word. - Remove the non-character symbols. + Remove the Mark symbols. """ return ''.join( @@ -271,8 +271,6 @@ def __init__(self, sequence: Sequence[str]): f"Attempting to alias {c_un!r} to {c!r} but already exists as a non-alias" n.children[c] = n.children[c_un] - #print( f"Created Mapping for {len(self)} words {', '.join(self._words[:min(len(self),3)])}...{self._words[-1]}" ) - def __getitem__(self, key: Union[str, int]) -> int: """A Mapping from "word" to index, or the reverse. diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..f8e5c9f3 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,22 @@ +[mypy] +pretty = True +show_error_codes = True + +python_version = 3.9 +# Some packages have the same name, eg. clients.bip44, hds.bip44 and derivations.bip44 +explicit_package_bases = True +warn_return_any = true +warn_unused_configs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +exclude = ^(examples)$ + diff --git a/requirements/tests.txt b/requirements/tests.txt index 3cb77c0a..7736ca08 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,3 +1,4 @@ pytest>=8.3.2,<9 coverage>=7.6.4,<8 tox>=4.23.2,<5 +mypy From be6236c7b45489ec5d42de26744ea73c92bc0923 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sun, 5 Oct 2025 07:54:07 -0700 Subject: [PATCH 30/38] Improve memory of SLIP-39 language encoding --- hdwallet/mnemonics/imnemonic.py | 22 +++-- hdwallet/mnemonics/slip39/mnemonic.py | 96 ++++++++++++------- .../mnemonics/test_mnemonics_slip39.py | 54 ++++++++--- 3 files changed, 117 insertions(+), 55 deletions(-) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index ba19a80c..a3837c12 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -375,22 +375,25 @@ def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: mnemonic_list: List[str] = self.normalize(mnemonic) # Attempt to unambiguously determine the Mnemonic's language using any preferred 'language'. - # Raises a MnemonicError if the words are not valid. + # Raises a MnemonicError if the words are not valid. Note that the supplied preferred + # language is only a hint, and the actual language matching the mnemonic will be selected. self._word_indices, self._language = self.find_language(mnemonic_list, language=kwargs.get("language")) self._mnemonic_type = kwargs.get("mnemonic_type", None) # We now know with certainty that the list of Mnemonic words was valid in some language. # However, they may have been abbreviations, or had optional UTF-8 Marks removed. So, use # the _word_indices mapping twice, from str (matching word/abbrev) -> int (index) -> str - # (canonical word) + # (canonical word from keys). This will work with a find_languages that returns either a + # WordIndices Mapping or a simple dict (but not abbreviations or missing Marks will be + # supported) self._mnemonic: List[str] = [ - self._word_indices[self._word_indices[word]] + self._word_indices.keys()[self._word_indices[word]] for word in mnemonic_list ] self._words = len(self._mnemonic) - # We have the canonical Mnemonic words. Decode them, preserving the real MnemonicError - # details if the words do not form a valid Mnemonic. + # We have the canonical Mnemonic words. Decode them for validation, thus preserving the + # real MnemonicError details if the words do not form a valid Mnemonic. self.decode(self._mnemonic, **kwargs) @classmethod @@ -558,10 +561,11 @@ def find_language( language: Optional[str] = None, ) -> Tuple[Mapping[str, int], str]: """Finds the language of the given mnemonic by checking against available word list(s), - preferring the specified 'language' if one is supplied. If a 'wordlist_path' dict of - {language: path} is supplied, its languages are used. If a 'language' (optional) is - supplied, any ambiguity is resolved by selecting the preferred language, if available and - the mnemonic matches. If not, the least ambiguous language found is selected. + preferring the specified 'language' if supplied and exactly matches an available language. + If a 'wordlist_path' dict of {language: path} is supplied, its languages are used. If a + 'language' (optional) is supplied, any ambiguity is resolved by selecting the preferred + language, if available and the mnemonic matches. If not, the least ambiguous language found + is selected. If an abbreviation match is found, then the language with the largest total number of symbols matched (least ambiguity) is considered best. This handles the (rare) case where a diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index 292f7989..9bdcff5f 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -197,7 +197,7 @@ def ordinal( num ): def tabulate_slip39( groups: Dict[Union[str, int], Tuple[int, int]], group_mnemonics: Sequence[Collection[str]], - columns=None, # default: columnize, but no wrapping + columns: Optional[Union[bool, int]]=None, # default: columnize, but no wrapping ) -> str: """Return SLIP-39 groups with group names/numbers, a separator, and tabulated mnemonics. @@ -274,6 +274,7 @@ def prefixed( groups, group_mnemonics ): yield ["", "", con] if con else [None] return tabulate( prefixed( groups, group_mnemonics ), tablefmt='plain' ) + tabulate_slip39.default = 20 # noqa: E305 @@ -347,15 +348,22 @@ class SLIP39Mnemonic(IMnemonic): } def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: - # Record the mnemonics, and the specified language. Computes _words simply for a standard - # single-phrase mnemonic. The language string supplied will + # Record the mnemonics, and the specified language. First handle any custom keywords + self._tabulate = kwargs.pop("tabulate", False) + super().__init__(mnemonic, **kwargs) + # We know that normalize has already validated _mnemonic's length. Compute the per-mnemonic - # words for SLIP-39. + # words for SLIP-39; exactly one of the available SLIP-39 words_list lengths will divide the + # mnemonics evenly. self._words, = filter(lambda w: len(self._mnemonic) % w == 0, self.words_list) - # If a certain tabulation is desired for human readability, remember it. - self._tabulate = kwargs.get("tabulate", False) - + + # If a SLIP-39 language encoding and/or tabulation is desired, remember them. The default + # behavior deduces and stores the Mnemonic language; we want to remember any custom + # encoding supplied. + if kwargs.get("language"): + self._language = kwargs.get("language") + @classmethod def name(cls) -> str: """ @@ -367,27 +375,30 @@ def name(cls) -> str: return "SLIP39" def mnemonic(self) -> str: - """ - Get the mnemonic as a single string. + """Get the mnemonic as a single string. SLIP-39 Mnemonics usually have multiple lines. Iterates the _mnemonic words list by the computed self.words(), joining each length of words by spaces to for a line, and then joins by newlines. + If a non-default 'tabulate' (eg. True, None, ) was specified, attempt to use it and the + stored SLIP-39 encoding language to inform the tabulation. + :return: The mnemonic as a single string joined by spaces and newlines. :rtype: str """ if self._tabulate is not False: - # Output the mnemonics with their language details and desired tabulation. We'll need - # to re-deduce the SLIP-39 secret and group specs from _language. Only if we successfully - # compute the same number of expected mnemonics, will we assume that everything - # is OK (someone hasn't created a SLIP39Mnemonic by hand with a custom _language and _mnemonics), - # and we'll output the re- - ((s_name, (s_thresh, s_size)), groups), = language_parser(language).items() - mnemonic = iter( self._mnemonics ) + # Output the SLIP-39 mnemonics with their encoding language details and desired + # tabulation. We'll need to re-deduce the SLIP-39 secret and group specs from + # _language. Only if we successfully compute the same number of expected mnemonics and + # exactly match the expected prefixes, will we assume that everything is OK (someone + # hasn't created a SLIP39Mnemonic by hand with a custom _language and _mnemonics), and + # we'll output the tabulated mnemonics. + ((s_name, (s_thresh, s_size)), groups), = language_parser(self._language).items() + mnemonic = iter( self._mnemonic ) try: - group_mnemonics: List[List[str]] =[ + group_mnemonics: List[List[str]] = [ [ " ".join( next( mnemonic ) for _ in range( self._words )) for _ in range( g_size ) @@ -402,26 +413,31 @@ def mnemonic(self) -> str: extras = list(mnemonic) if not extras: # Exactly consumed all _mnemonics according to SLIP-39 language spec! Success? - # One final check; all group_mnemonics should have a common prefix. + # Final check; each of the group_mnemonics lists should have a common prefix. def common( strings: List[str] ) -> str: prefix = None for s in strings: - if common is None: + if prefix is None: prefix = s continue - for i, (cp, cs) in zip(prefix, s): + for i, (cp, cs) in enumerate(zip(prefix, s)): if cp != cs: prefix = prefix[:i] + break if not prefix: break return prefix if all( map( common, group_mnemonics )): - return tabulate_slip39( groups, group_mnemonics, columns=self._tabulate ) + return tabulate_slip39( + groups=groups, + group_mnemonics=group_mnemonics, + columns=self._tabulate + ) # Either no common prefix in some group; Invalid deduction of group specs - # vs. mnemonics., or left-over Mnemonics! Fall through and render it the - # old-fashioned way... + # vs. mnemonics, or left-over/insufficient Mnemonics! Fall through and render it + # the old-fashioned way... mnemonic_chunks: Iterable[List[str]] = zip(*[iter(self._mnemonic)] * self._words) mnemonic: Iterable[str] = map(" ".join, mnemonic_chunks) @@ -488,17 +504,34 @@ def encode( iteration_exponent: int = 1, tabulate: bool = False, # False disables; any other value causes prefixing/columnization ) -> str: - """ - Encodes entropy into a mnemonic phrase. + """Encodes entropy into a SLIP-39 mnemonic phrase according to the specified language. + + The language specifies the SLIP-39 encoding parameters, and not the mnemonic language (which + is always english). The SLIP-39 encoding has A Name optionally followed by the number of + required groups / total groups (will be deduced if missing), followed by 1 or more comma + separated group names, each optionally with a number of mnemonics required to recover the + group, and optionally with a / followed by the total number of mnemonics to produce. + + Family Name 3: Home 1, Office 1, Fam 2, Frens 3, Other + Family Name 3/5 < Home 1/1, Office 1/1, Fam 2, Frens 3, Other > + Family Name 3/5 < Home 1/1, Office 1/1, Fam 2, Frens 3, Other 5/10> - This method converts a given entropy value into a mnemonic phrase according to the specified - language. SLIP-39 mnemonics include a password. This is normally empty, and is not well supported even on Trezor devices. It is better to use SLIP-39 to encode a BIP-39 Mnemonic's entropy and then (after recovering it from SLIP-39), use a BIP-39 passphrase (which is well - supported across all devices), or use the "Passphrase Wallet" feature of your hardware wallet - device. + supported across all devices), or use the "Passphrase Wallet" feature of your hardware + wallet device. + + When a password is supplied to encode, decode will always recover the original entropy with + the same password. The 'extendable' feature (now default) of SLIP-39 provides for + deterministically recovering a deterministic entropy for each *different* password supplied. + This supports the use case where the original entropy and password is used (but different + SLIP-39 encoding parameters are supplied) -- but the same decoded wallet entropies are + desired for multiple different passwords. + + Note that SLIP-39 includes additional entropy in the encoding process, so the same entropy + and password will always result in different output SLIP-39 mnemonics. :param entropy: The entropy to encode into a mnemonic phrase. :type entropy: Union[str, bytes] @@ -506,6 +539,8 @@ def encode( :type language: str :param passphrase: The SLIP-39 passphrase (default: "") :type passphrase: str + :param extendable: Derive deterministic entropy for alternate passwords + :type extendable: bool :return: The encoded mnemonic phrase. :rtype: str @@ -559,9 +594,6 @@ def decode( """ mnemonic_list: List[str] = cls.normalize(mnemonic) try: - if language and language not in cls.languages: - raise ValueError( f"Invalid SLIP-39 language: {language}" ) - mnemonic_words, = filter(lambda words: len(mnemonic_list) % words == 0, cls.words_list) mnemonic_chunks: Iterable[List[str]] = zip(*[iter(mnemonic_list)] * mnemonic_words) mnemonic_lines: Iterable[str] = map(" ".join, mnemonic_chunks) diff --git a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py index 45203998..111930ea 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py @@ -60,6 +60,16 @@ def test_slip39_language(): }, } + assert language_parser("Fibonacci Defaults 3 / 5") == { + ("Fibonacci Defaults",(3,5)): { + 0: (1,1), + 1: (1,1), + 2: (2,4), + 3: (3,6), + 4: (5,10), + }, + } + def test_slip39_mnemonics(): @@ -133,19 +143,6 @@ def test_slip39_mnemonics(): ) -def test_slip39_init(): - """Details of the SLIP-39 specifications' 'language' and output 'tabulate' value must be kept, - so .mnemonic() reflects them. - - """ - for entropy in [ - "ff" * (128//8), - "ff" * (256//8), - "ff" * (512//8), - ]: - pass - - class substitute( contextlib.ContextDecorator ): """The SLIP-39 standard includes random data in portions of the as share. Replace the random function during testing to get determinism in resultant nmenomics. @@ -167,13 +164,19 @@ def __exit__( self, *exc ): @substitute( shamir_mnemonic.shamir, 'RANDOM_BYTES', lambda n: b'\0' * n ) def test_slip39_tabulate(): + """Details of the SLIP-39 specifications' 'language' and output 'tabulate' value must be kept, + so .mnemonic() reflects them. + + """ entropy_128 = "ff"*(128//8) entropy_256 = "ff"*(256//8) entropy_512 = "ff"*(512//8) + + family = "Perry Kundert [ One 1/1, Two 1/1, Fam 2/4, Frens 3/6 ]" - assert SLIP39Mnemonic.encode(entropy=entropy_128, language=family, tabulate=None) == """\ + family_tabulate_None = """\ One 1/1 1st ━ academic agency acrobat romp course prune deadline umbrella darkness salt bishop impact vanish squeeze moment segment privacy bolt making enjoy Two 1/1 1st ━ academic agency beard romp downtown inmate hamster counter rainbow grocery veteran decorate describe bedroom disease suitable peasant editor welfare spider @@ -198,6 +201,29 @@ def test_slip39_tabulate(): ╏ 6th ┗ academic agency decision spider earth woman gasoline dryer civil deliver laser hospital mountain wrist clinic evidence database public dwarf lawsuit""" + family_tabulate_False = """\ +academic agency acrobat romp course prune deadline umbrella darkness salt bishop impact vanish squeeze moment segment privacy bolt making enjoy +academic agency beard romp downtown inmate hamster counter rainbow grocery veteran decorate describe bedroom disease suitable peasant editor welfare spider +academic agency ceramic roster crystal critical forbid sled building glad legs angry enlarge ting ranked round solution legend ending lips +academic agency ceramic scared drink verdict funding dragon activity verify fawn yoga devote perfect jacket database picture genius process pipeline +academic agency ceramic shadow avoid leaf fantasy midst crush fraction cricket taxi velvet gasoline daughter august rhythm excuse wrist increase +academic agency ceramic sister capital flexible favorite grownup diminish sidewalk yelp blanket market class testify temple silent prevent born galaxy +academic agency decision round academic academic academic academic academic academic academic academic academic academic academic academic academic phrase trust golden +academic agency decision scatter desert wisdom birthday fatigue lecture detailed destroy realize recover lilac genre venture jacket mountain blessing pulse +academic agency decision shaft birthday debut benefit shame market devote angel finger traveler analysis pipeline extra funding lawsuit editor guilt +academic agency decision skin category skin alpha observe artwork advance earth thank fact material sheriff peaceful club evoke robin revenue +academic agency decision snake anxiety acrobat inform home patrol alpha erode steady cultural juice emerald reject flash license royal plunge +academic agency decision spider earth woman gasoline dryer civil deliver laser hospital mountain wrist clinic evidence database public dwarf lawsuit""" + + assert SLIP39Mnemonic.encode(entropy=entropy_128, language=family, tabulate=None) == family_tabulate_None + assert SLIP39Mnemonic.encode(entropy=entropy_128, language=family) == family_tabulate_False + + # Now, ensure that a SLIP39Mnemonic instance remembers its SLIP-39 encoding parameters and desired tabulation. + slip39 = SLIP39Mnemonic(mnemonic=family_tabulate_False, language=family, tabulate=None) + assert slip39.mnemonic() == family_tabulate_None + + + assert SLIP39Mnemonic.encode(entropy=entropy_512, language=family, tabulate=None) == """\ One 1/1 1st ━ academic agency acrobat romp acid airport meaning source sympathy junction symbolic lyrics install enjoy remind trend blind vampire type idle kind facility venture image inherit talent burning woman devote guest prevent news rich type unkind clay venture raisin oasis crisis firefly change index hanger belong true floral fawn busy fridge invasion member hesitate railroad campus edge ocean woman spill From a3b4c23a41498210084e0ace41633b815c80bc47 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 6 Oct 2025 05:28:57 -0700 Subject: [PATCH 31/38] Fix static analysis, add examples/mnemonics/slip39.py --- examples/mnemonics/slip39.py | 51 ++++++++++++++++++++++ hdwallet/cli/generate/mnemonic.py | 7 ++- hdwallet/mnemonics/bip39/mnemonic.py | 2 +- hdwallet/mnemonics/electrum/v1/mnemonic.py | 5 ++- hdwallet/mnemonics/electrum/v2/mnemonic.py | 2 +- hdwallet/mnemonics/imnemonic.py | 15 ++----- hdwallet/mnemonics/slip39/mnemonic.py | 12 ++--- hdwallet/seeds/algorand.py | 1 - hdwallet/seeds/bip39.py | 5 +-- hdwallet/seeds/cardano.py | 2 +- hdwallet/seeds/electrum/v1.py | 1 - hdwallet/seeds/electrum/v2.py | 1 - hdwallet/seeds/iseed.py | 2 +- hdwallet/seeds/monero.py | 1 - 14 files changed, 73 insertions(+), 34 deletions(-) create mode 100644 examples/mnemonics/slip39.py diff --git a/examples/mnemonics/slip39.py b/examples/mnemonics/slip39.py new file mode 100644 index 00000000..02c219bd --- /dev/null +++ b/examples/mnemonics/slip39.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +from typing import Type + +from hdwallet.mnemonics import ( + MNEMONICS, IMnemonic, SLIP39Mnemonic, SLIP39_MNEMONIC_LANGUAGES, SLIP39_MNEMONIC_WORDS +) + +data = { + "name": "SLIP39", + "entropy": "b66022fff8b6322f8b8fa444d6d097457b6b9e7bb05add5b75f9c827df7bd3b6", + "mnemonic": ( + "drug cleanup academic academic august branch cage company example duke" + " uncover glen already mortgage ticket emphasis papa agree fitness capacity" + " evening glad trust raspy year sweater hormone database kernel cultural" + " fact angry goat" + ), + "language": SLIP39_MNEMONIC_LANGUAGES.ENGLISH, + "words": SLIP39_MNEMONIC_WORDS.THIRTY_THREE, +} + +SLIP39MnemonicClass: Type[IMnemonic] = MNEMONICS.mnemonic(data["name"]) + +slip39_mnemonic_class = SLIP39MnemonicClass(data["mnemonic"]) +slip39_mnemonic = SLIP39Mnemonic(data["mnemonic"]) + +print( + slip39_mnemonic_class.decode(mnemonic=slip39_mnemonic_class.mnemonic()) + == slip39_mnemonic.decode(mnemonic=slip39_mnemonic.mnemonic()) + == slip39_mnemonic_class.decode(SLIP39MnemonicClass.from_entropy(data["entropy"], data["language"])) + == slip39_mnemonic.decode(SLIP39Mnemonic.from_entropy(data["entropy"], data["language"])) + == SLIP39Mnemonic.decode(mnemonic=data["mnemonic"]), + + slip39_mnemonic_class.language() == slip39_mnemonic.language() == data["language"], + + slip39_mnemonic_class.words() == slip39_mnemonic.words() == data["words"], + + SLIP39MnemonicClass.is_valid(data["mnemonic"]) == SLIP39Mnemonic.is_valid(data["mnemonic"]), + + SLIP39MnemonicClass.is_valid_language(data["language"]) == SLIP39Mnemonic.is_valid_language(data["language"]), + + SLIP39MnemonicClass.is_valid_words(data["words"]) == SLIP39Mnemonic.is_valid_words(data["words"]), + + len(SLIP39MnemonicClass.from_words(data["words"], data["language"]).split(" ")) == + len(SLIP39Mnemonic.from_words(data["words"], data["language"]).split(" ")), "\n" +) + +print("Client:", data["name"]) +print("Mnemonic:", data["mnemonic"]) +print("Language:", data["language"]) +print("Words:", data["words"]) diff --git a/hdwallet/cli/generate/mnemonic.py b/hdwallet/cli/generate/mnemonic.py index 6b6b862c..78279425 100644 --- a/hdwallet/cli/generate/mnemonic.py +++ b/hdwallet/cli/generate/mnemonic.py @@ -80,7 +80,7 @@ def generate_mnemonic(**kwargs) -> None: if kwargs.get("entropy") and kwargs.get("mnemonic"): click.echo(click.style( - f"Supply either --entropy or --mnemonic, not both, " + "Supply either --entropy or --mnemonic, not both" ), err=True) sys.exit() @@ -102,7 +102,7 @@ def generate_mnemonic(**kwargs) -> None: mnemonic_type=kwargs.get("mnemonic_type") ) elif kwargs.get("mnemonic_client") == SLIP39Mnemonic.name(): - entropy: str = SLIPMnemonic.decode( + entropy: str = SLIP39Mnemonic.decode( mnemonic=kwargs.get("mnemonic"), language=kwargs.get("language"), passphrase=kwargs.get("mnemonic_passphrase") or "", @@ -143,11 +143,10 @@ def generate_mnemonic(**kwargs) -> None: # can also contain specifics like the SLIP-39's overall name and groups' names. Any # 'tabulate' supplied influences the formatting of the groups of SLIP-39 Mnemonics. mnemonic: IMnemonic = SLIP39Mnemonic( - mnemonic=SLILP39Mnemonic.from_entropy( + mnemonic=SLIP39Mnemonic.from_entropy( entropy=kwargs.get("entropy"), language=language, passphrase=kwargs.get("passphrase") or "", - checksum=kwargs.get("checksum") ), language=language, tabulate=kwargs.get("tabulate", False), diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index 2ecdf819..37f4d097 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -283,7 +283,7 @@ def decode( wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None if words_list: if not language: - raise Error( f"Must provide language with words_list" ) + raise Error( "Must provide language with words_list" ) wordlist_path = { language: words_list } words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) if len(words_list_with_index) != cls.words_list_number: diff --git a/hdwallet/mnemonics/electrum/v1/mnemonic.py b/hdwallet/mnemonics/electrum/v1/mnemonic.py index c78dd14c..1ce3f1e3 100644 --- a/hdwallet/mnemonics/electrum/v1/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v1/mnemonic.py @@ -12,7 +12,7 @@ IEntropy, ElectrumV1Entropy, ELECTRUM_V1_ENTROPY_STRENGTHS ) from ....exceptions import ( - EntropyError, MnemonicError + Error, EntropyError, MnemonicError ) from ....utils import ( get_bytes, integer_to_bytes, bytes_to_integer, bytes_to_string @@ -210,7 +210,8 @@ def decode( if not words_list_with_index: wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None if words_list: - assert language, f"Must provide language with words_list" + if not language: + raise Error( "Must provide language with words_list" ) wordlist_path = { language: words_list } words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) if len(words_list_with_index) != cls.words_list_number: diff --git a/hdwallet/mnemonics/electrum/v2/mnemonic.py b/hdwallet/mnemonics/electrum/v2/mnemonic.py index 6321ca0f..c6ba2276 100644 --- a/hdwallet/mnemonics/electrum/v2/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v2/mnemonic.py @@ -225,7 +225,7 @@ def from_entropy( language=language, wordlist_path=cls.wordlist_path ) bip39_words_indices: Optional[List[str]] = None - (_, _ ,bip39_words_indices), = BIP39Mnemonic.wordlist_indices(language=language) + (_, _, bip39_words_indices), = BIP39Mnemonic.wordlist_indices(language=language) electrum_v1_words_indices: Optional[List[str]] = None try: (_, _, electrum_v1_words_indices), = ElectrumV1Mnemonic.wordlist_indices(language=language) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index a3837c12..6a7a01f1 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -46,6 +46,7 @@ class TrieNode: """ EMPTY = None PRESENT = True + def __init__(self): self.children = defaultdict(self.__class__) self.value = self.__class__.EMPTY @@ -138,7 +139,7 @@ def scan( prefix: str = '', current: Optional[TrieNode] = None, depth: int = 0, - predicate: Optional[Callable[[TrieNode], bool]] = None, # default: terminal + predicate: Optional[Callable[[TrieNode], bool]] = None, # default: terminal ) -> Generator[Tuple[str, TrieNode], None, None]: """Yields all strings and their TrieNode that match 'prefix' and satisfy 'predicate' (or are terminal), in depth-first order. @@ -640,11 +641,7 @@ def find_language( # found to be unique abbreviations of words in the candidate, but it isn't the # preferred language (or no preferred language was specified). Keep track of its # quality of match, but carry on testing other candidate languages. - except (MnemonicError, ValueError) as exc: - # print( - # f"Unrecognized mnemonic: {exc}" - # # f" w/ indices:\n{words_indices}" - # ) + except (MnemonicError, ValueError): continue # No unambiguous match to any preferred language found (or no language matched all words). @@ -683,11 +680,7 @@ def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = Non try: cls.decode(mnemonic=mnemonic, language=language, **kwargs) return True - except (ValueError, KeyError, MnemonicError, ChecksumError) as exc: - # print( - # f"Invalid mnemonic: {exc}" - # # f" w/ indices:\n{words_indices}" - # ) + except (ValueError, KeyError, MnemonicError, ChecksumError): return False @classmethod diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index 9bdcff5f..4494b05e 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -197,7 +197,7 @@ def ordinal( num ): def tabulate_slip39( groups: Dict[Union[str, int], Tuple[int, int]], group_mnemonics: Sequence[Collection[str]], - columns: Optional[Union[bool, int]]=None, # default: columnize, but no wrapping + columns: Optional[Union[bool, int]] = None, # default: columnize, but no wrapping ) -> str: """Return SLIP-39 groups with group names/numbers, a separator, and tabulated mnemonics. @@ -427,7 +427,7 @@ def common( strings: List[str] ) -> str: if not prefix: break return prefix - + if all( map( common, group_mnemonics )): return tabulate_slip39( groups=groups, @@ -438,7 +438,7 @@ def common( strings: List[str] ) -> str: # Either no common prefix in some group; Invalid deduction of group specs # vs. mnemonics, or left-over/insufficient Mnemonics! Fall through and render it # the old-fashioned way... - + mnemonic_chunks: Iterable[List[str]] = zip(*[iter(self._mnemonic)] * self._words) mnemonic: Iterable[str] = map(" ".join, mnemonic_chunks) return "\n".join(mnemonic) @@ -479,9 +479,9 @@ def from_entropy(cls, entropy: Union[str, bytes, IEntropy], language: str, **kwa :rtype: str """ if isinstance(entropy, str) or isinstance(entropy, bytes): - return cls.encode(entropy=entropy, language=language) + return cls.encode(entropy=entropy, language=language, **kwargs) elif isinstance(entropy, IEntropy) and entropy.strength() in SLIP39Entropy.strengths: - return cls.encode(entropy=entropy.entropy(), language=language) + return cls.encode(entropy=entropy.entropy(), language=language, **kwargs) raise EntropyError( "Invalid entropy instance", expected=[str, bytes,]+list(ENTROPIES.dictionary.values()), got=type(entropy) ) @@ -579,7 +579,7 @@ def decode( The passphrase has no verification; all derived entropies are considered equivalently valid (you can use several passphrases to recover multiple, distinct sets of entropy.) So, it is solely your responsibility to remember your correct passphrase(s): this is a design feature - of SLIP-39. The default "extendable" SLIP-39 + of SLIP-39. :param mnemonic: The mnemonic phrase to decode. :type mnemonic: str diff --git a/hdwallet/seeds/algorand.py b/hdwallet/seeds/algorand.py index b39c6ba1..c39efc99 100644 --- a/hdwallet/seeds/algorand.py +++ b/hdwallet/seeds/algorand.py @@ -6,7 +6,6 @@ from typing import Optional, Union -from ..exceptions import MnemonicError from ..mnemonics import ( IMnemonic, AlgorandMnemonic ) diff --git a/hdwallet/seeds/bip39.py b/hdwallet/seeds/bip39.py index 28885cf9..906db9e9 100644 --- a/hdwallet/seeds/bip39.py +++ b/hdwallet/seeds/bip39.py @@ -11,7 +11,6 @@ import unicodedata from ..crypto import pbkdf2_hmac_sha512 -from ..exceptions import MnemonicError from ..utils import bytes_to_string from ..mnemonics import ( IMnemonic, BIP39Mnemonic @@ -80,8 +79,8 @@ def from_mnemonic( # Normalize mnemonic to NFKD for seed generation as required by BIP-39 specification normalized_mnemonic: str = unicodedata.normalize("NFKD", mnemonic.mnemonic()) - - # Salt normalization should use NFKD as per BIP-39 specification + + # Salt normalization should use NFKD as per BIP-39 specification salt: str = unicodedata.normalize("NFKD", ( (cls.seed_salt_modifier + passphrase) if passphrase else cls.seed_salt_modifier )) diff --git a/hdwallet/seeds/cardano.py b/hdwallet/seeds/cardano.py index b2a19884..a3079021 100644 --- a/hdwallet/seeds/cardano.py +++ b/hdwallet/seeds/cardano.py @@ -17,7 +17,7 @@ from ..cryptocurrencies import Cardano from ..crypto import blake2b_256 from ..exceptions import ( - Error, MnemonicError, SeedError + Error, SeedError ) from ..utils import ( get_bytes, bytes_to_string diff --git a/hdwallet/seeds/electrum/v1.py b/hdwallet/seeds/electrum/v1.py index 8b98a12b..308b0a7f 100644 --- a/hdwallet/seeds/electrum/v1.py +++ b/hdwallet/seeds/electrum/v1.py @@ -7,7 +7,6 @@ from typing import Optional, Union from ...crypto import sha256 -from ...exceptions import MnemonicError from ...mnemonics import ( IMnemonic, ElectrumV1Mnemonic ) diff --git a/hdwallet/seeds/electrum/v2.py b/hdwallet/seeds/electrum/v2.py index 2f3644e7..9a67753c 100644 --- a/hdwallet/seeds/electrum/v2.py +++ b/hdwallet/seeds/electrum/v2.py @@ -11,7 +11,6 @@ import unicodedata from ...crypto import pbkdf2_hmac_sha512 -from ...exceptions import MnemonicError from ...utils import bytes_to_string from ...mnemonics import ( IMnemonic, ElectrumV2Mnemonic, ELECTRUM_V2_MNEMONIC_TYPES diff --git a/hdwallet/seeds/iseed.py b/hdwallet/seeds/iseed.py index 6807342a..628dd9aa 100644 --- a/hdwallet/seeds/iseed.py +++ b/hdwallet/seeds/iseed.py @@ -62,7 +62,7 @@ def seed(self) -> str: :type mnemonic: Union[str, IMnemonic] :param language: The preferred language, if known :type language: Optional[str] - + :return: The seed as a string. :rtype: str """ diff --git a/hdwallet/seeds/monero.py b/hdwallet/seeds/monero.py index df4bcbd7..185e9637 100644 --- a/hdwallet/seeds/monero.py +++ b/hdwallet/seeds/monero.py @@ -6,7 +6,6 @@ from typing import Optional, Union -from ..exceptions import MnemonicError from ..mnemonics import ( IMnemonic, MoneroMnemonic ) From 0f0325faa1114bb57f77d182dac56c79416dc516 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 6 Oct 2025 08:54:29 -0700 Subject: [PATCH 32/38] Begin working through some mypy typing issues --- hdwallet/libs/base58.py | 3 +-- hdwallet/mnemonics/imnemonic.py | 32 ++++++++++++++++++-------------- mypy.ini | 6 +----- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/hdwallet/libs/base58.py b/hdwallet/libs/base58.py index 07b2a228..6289655b 100644 --- a/hdwallet/libs/base58.py +++ b/hdwallet/libs/base58.py @@ -60,8 +60,7 @@ def check_encode(raw, alphabet=__base58_alphabet): def decode(data, alphabet=__base58_alphabet): - if bytes != str: - data = bytes(data, "ascii") + data = bytes(data, "ascii") val = 0 prefix = 0 diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 6a7a01f1..8cacd47d 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -11,7 +11,7 @@ abc ) from typing import ( - Any, Callable, Dict, Generator, List, Mapping, Optional, Sequence, Set, Tuple, Union + Any, Callable, Dict, Generator, List, Mapping, MutableMapping, Optional, Sequence, Set, Tuple, Union ) import os @@ -48,8 +48,8 @@ class TrieNode: PRESENT = True def __init__(self): - self.children = defaultdict(self.__class__) - self.value = self.__class__.EMPTY + self.children: MutableMapping[str, TrieNode] = defaultdict(self.__class__) + self.value: Any = self.__class__.EMPTY class Trie: @@ -223,6 +223,8 @@ def unmark( word_composed: str ) -> str: class WordIndices( abc.Mapping ): """A Mapping which holds a Sequence of Mnemonic words. + The underlying Trie is built during construction, but a WordIndices Mapping is not mutable. + Acts like a basic { "word": index, ... } dict but with additional word flexibility. Also behaves like a ["word", "word", ...] list for iteration and indexing. @@ -244,12 +246,12 @@ class WordIndices( abc.Mapping ): """ def __init__(self, sequence: Sequence[str]): - """Insert a sequence of Unicode words (and optionally value(s)) into a Trie, making the + """Insert a sequence of Unicode words with a value equal to the enumeration, making the "unmarked" version an alias of the regular Unicode version. """ self._trie = Trie() - self._words = [] + self._words: List[str] = [] for i, word in enumerate( sequence ): self._words.append( word ) word_unmarked = unmark( word ) @@ -265,14 +267,15 @@ def __init__(self, sequence: Sequence[str]): # never get a None (lose the plot) because we've just inserted 'word'! This will # "alias" each glyph with a mark, to the .children entry for the non-marked glyph. self._trie.insert( word_unmarked, i ) - for c, c_un, (_, _, n) in zip( word, word_unmarked, self._trie.find( word )): + for c, c_un, (_, _, n) in zip( word, word_unmarked, self._trie.find( word_unmarked )): + assert n is not None if c != c_un: if c in n.children and c_un in n.children: assert n.children[c_un] is n.children[c], \ - f"Attempting to alias {c_un!r} to {c!r} but already exists as a non-alias" + f"Attempting to alias {c!r} to {c_un!r} but already exists as a non-alias" n.children[c] = n.children[c_un] - def __getitem__(self, key: Union[str, int]) -> int: + def __getitem__(self, key: Union[str, int]) -> Union[int, str]: """A Mapping from "word" to index, or the reverse. Any unique abbreviation with/without UTF-8 "Marks" is accepted. We keep this return value @@ -296,11 +299,11 @@ def get_details(self, key: Union[int, str]) -> Tuple[str, int, Set[str]]: # The key'th word (or IndexError) return self._words[key], key, set() - terminal, prefix, node = self._trie.search( key, complete=Trie ) + terminal, prefix, node = self._trie.search( key, complete=True ) if not terminal: # We're nowhere in the Trie with this word raise KeyError(f"{key!r} does not match any word") - + assert node is not None return self._words[node.value], node.value, set(node.children) def __len__(self): @@ -352,7 +355,7 @@ class IMnemonic(ABC): _words: int _language: str _mnemonic_type: Optional[str] - _word_indices: Dict[str, int] + _word_indices: Mapping[str, int] words_list: List[int] # The valid mnemonic length(s) available, in words languages: List[str] @@ -385,10 +388,11 @@ def __init__(self, mnemonic: Union[str, List[str]], **kwargs) -> None: # However, they may have been abbreviations, or had optional UTF-8 Marks removed. So, use # the _word_indices mapping twice, from str (matching word/abbrev) -> int (index) -> str # (canonical word from keys). This will work with a find_languages that returns either a - # WordIndices Mapping or a simple dict (but not abbreviations or missing Marks will be - # supported) + # WordIndices Mapping or a simple dict word->index Mapping (but abbreviations or missing + # Marks will not be supported) + canonical_words = list(self._word_indices) self._mnemonic: List[str] = [ - self._word_indices.keys()[self._word_indices[word]] + canonical_words[self._word_indices[word]] for word in mnemonic_list ] self._words = len(self._mnemonic) diff --git a/mypy.ini b/mypy.ini index f8e5c9f3..698cb52b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -15,8 +15,4 @@ warn_no_return = true warn_unreachable = true strict_equality = true -# Exclude _files.py because mypy isn't smart enough to apply -# the correct type narrowing and as this is an internal module -# it's fine to just use Pyright. -exclude = ^(examples)$ - +exclude = ^(examples/|docs/|build/)$ From 7d507d512e4b33dc33abe88eabdd8b913df7f129 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Tue, 7 Oct 2025 07:41:33 -0600 Subject: [PATCH 33/38] Implement IMnemonic.collect to support collecting a mnemonic word --- hdwallet/mnemonics/imnemonic.py | 64 ++++++++++++++++++- .../mnemonics/test_mnemonics_slip39.py | 6 +- tests/test_bip39_cross_language.py | 31 ++++++++- 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 8cacd47d..054d33e3 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -11,7 +11,7 @@ abc ) from typing import ( - Any, Callable, Dict, Generator, List, Mapping, MutableMapping, Optional, Sequence, Set, Tuple, Union + Any, Callable, Collection, Dict, Generator, List, Mapping, MutableMapping, Optional, Sequence, Set, Tuple, Union ) import os @@ -163,6 +163,25 @@ def scan( for suffix, found in self.scan( current=child, depth=max(0, depth-1), predicate=predicate ): yield prefix + char + suffix, found + def options( + self, + prefix: str = '', + current: Optional[TrieNode] = None, + ) -> Generator[Tuple[bool, Set[str]], str, None]: + """With each symbol provided, yields the next available symbol options. + + Doesn't advance unless a truthy symbol is provided via send(symbol). + + Completes when the provided symbol doesn't match one of the available options. + """ + last: str = '' + *_, (terminal, _, current) = self.find(prefix, current=current) + while current is not None: + terminal = current.value is not current.EMPTY + symbol: str = yield (terminal, set(current.children)) + if symbol: + current = current.children.get(symbol) + def dump_lines( self, current: Optional[TrieNode] = None, @@ -343,6 +362,9 @@ def unique( current ): # Only abbreviations (not terminal words) that led to a unique terminal word yield abbrev + def options(self, *args, **kwargs): + return self._trie.options(*args, **kwargs) + def __str__(self): return str(self._trie) @@ -663,6 +685,46 @@ def find_language( return language_indices[candidate], candidate + @classmethod + def collect( + cls, + languages: Optional[Collection[str]] = None, + wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, + ) -> Generator[Tuple[Set[str], bool, Set[str]], str, None]: + """A generator taking input symbols, and producing a sequence of sets of possible next + characters in all remaining languages. + + With each symbol provided, yields the remaining candidate languages, whether the symbol + indicated a terminal word in some language, and the available next symbols in all remaining + languages. + + """ + candidates: Dict[str, WordIndices] = dict( + (candidate, words_indices) + for candidate, _, words_indices in cls.wordlist_indices( wordlist_path=wordlist_path ) + if languages is None or candidate in languages + ) + + word: str = '' + updaters = { + candidate: words_indices.options() + for candidate, words_indices in candidates.items() + } + + symbol = None + complete = set() + while complete < set(updaters): + terminal = False + possible = set() + for candidate, updater in updaters.items(): + try: + done, available = updater.send(symbol) + except StopIteration: + complete.add( candidate ) + terminal |= done + possible |= available + symbol = yield (set(updaters) - complete, terminal, possible) + @classmethod def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = None, **kwargs) -> bool: """Checks if the given mnemonic is valid. diff --git a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py index 111930ea..240c85ed 100644 --- a/tests/hdwallet/mnemonics/test_mnemonics_slip39.py +++ b/tests/hdwallet/mnemonics/test_mnemonics_slip39.py @@ -2,8 +2,11 @@ import pytest from hdwallet.exceptions import MnemonicError +from hdwallet.mnemonics.imnemonic import ( + Trie, WordIndices, +) from hdwallet.mnemonics.slip39.mnemonic import ( - SLIP39Mnemonic, language_parser, group_parser + SLIP39Mnemonic, language_parser, group_parser, ) import shamir_mnemonic @@ -70,7 +73,6 @@ def test_slip39_language(): }, } - def test_slip39_mnemonics(): # Ensure our prefix and whitespace handling works correctly diff --git a/tests/test_bip39_cross_language.py b/tests/test_bip39_cross_language.py index 55eeb617..a6736153 100644 --- a/tests/test_bip39_cross_language.py +++ b/tests/test_bip39_cross_language.py @@ -135,7 +135,7 @@ def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_att except ChecksumError as exc: # Skip invalid mnemonics (e.g., checksum failures) continue - + success_rate = len(successful_both_languages) / total_attempts print(f"{words}-word mnemonics: {len(successful_both_languages)}/{total_attempts} successful ({success_rate:.6f})") @@ -438,7 +438,22 @@ class CustomTrieNode(TrieNode): o u n t == 16 u s e == 17 h i e v e == 18""" - + + + + options = trie.options() + + assert next( options ) == (False, set( 'a' )) + assert options.send( 'a' ) == (False, set( 'bcd' )) + assert options.send( 'd' ) == (False, set( 'dj' )) + assert options.send( 'd' ) == (True, set( 'ir' )) + assert options.send( 'i' ) == (False, set( 'c' )) + assert options.send( 'c' ) == (False, set( 't' )) + assert next(options) == (False, set( 't' )) + assert options.send('') == (False, set( 't' )) + assert options.send( 't' ) == (True, set()) + + def test_ambiguous_languages(self): """Test that find_language correctly detects and raises errors for ambiguous mnemonics. @@ -522,6 +537,18 @@ def test_ambiguous_languages(self): raise # Re-raise unexpected errors +def test_bip39_collection(): + + languages = {'english', 'french', 'spanish', 'russian'} + + collect = BIP39Mnemonic.collect(languages=languages) + assert collect.send(None) == (languages, False, set('abcedefghijklmnopqrstuvwxyzáéíóúабвгдежзиклмнопрстуфхцчшщэюя')) + assert collect.send('a') == ({'english', 'french', 'spanish'}, False, set('bcedefghijlmnpqrstuvwxyzéñ')) + assert collect.send('d') == ({'english', 'french', 'spanish'}, False, set('adehijmoruvé')) + assert collect.send('d') == ({'english'}, True , set('ir')) + + + def test_bip39_korean(): # Confirm that UTF-8 Mark handling works in other languages (particularly Korean) (_, korean_nfc, korean_indices), = BIP39Mnemonic.wordlist_indices( From 2a3adb41c0cf15473ed73464e2451a6079f07520 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Wed, 22 Oct 2025 07:17:59 -0600 Subject: [PATCH 34/38] Remove some pytest output, and some unnecessary Electrum v2 validity tests --- Makefile | 2 +- hdwallet/mnemonics/electrum/v2/mnemonic.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 772265c2..c5b88aae 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ export PYTHON ?= $(shell python3 --version >/dev/null 2>&1 && echo python3 || e PYTHON_V = $(shell $(PYTHON) -c "import sys; print('-'.join((('venv' if sys.prefix != sys.base_prefix else next(iter(filter(None,sys.base_prefix.split('/'))))),sys.platform,sys.implementation.cache_tag)))" 2>/dev/null ) export PYTEST ?= $(PYTHON) -m pytest -export PYTEST_OPTS ?= -vv --capture=no +export PYTEST_OPTS ?= # -vv --capture=no VERSION = $(shell $(PYTHON) -c "exec(open('hdwallet/info.py').read()); print(__version__[1:])" ) diff --git a/hdwallet/mnemonics/electrum/v2/mnemonic.py b/hdwallet/mnemonics/electrum/v2/mnemonic.py index c6ba2276..813de9eb 100644 --- a/hdwallet/mnemonics/electrum/v2/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v2/mnemonic.py @@ -345,9 +345,6 @@ def decode( if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - # if not cls.is_valid(mnemonic, language=language, mnemonic_type=mnemonic_type): - # raise MnemonicError(f"Invalid {mnemonic_type} mnemonic type words") - words_list_with_index, language = cls.find_language(mnemonic=words, language=language) if len(words_list_with_index) != cls.words_list_number: raise Error( From 8ed1b2f97459acd9422f491fccdd9504409d6d94 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sat, 25 Oct 2025 20:43:06 +0400 Subject: [PATCH 35/38] Correct IMnemonic.decode implementations to try all possible languages --- examples/hdwallet/bips/from_entropy.py | 2 +- examples/hdwallet/bips/from_entropy_slip39.py | 242 ++++++++++++++++++ hdwallet/consts.py | 13 +- hdwallet/cryptocurrencies/bitcoin.py | 2 +- hdwallet/cryptocurrencies/qtum.py | 4 +- hdwallet/hdwallet.py | 45 +++- hdwallet/mnemonics/algorand/mnemonic.py | 56 ++-- hdwallet/mnemonics/bip39/mnemonic.py | 98 ++++--- hdwallet/mnemonics/electrum/v1/mnemonic.py | 67 +++-- hdwallet/mnemonics/electrum/v2/mnemonic.py | 42 ++- hdwallet/mnemonics/imnemonic.py | 193 ++++++++++---- hdwallet/mnemonics/monero/mnemonic.py | 67 +++-- hdwallet/mnemonics/slip39/mnemonic.py | 7 +- hdwallet/seeds/iseed.py | 16 +- hdwallet/seeds/slip39.py | 7 +- tests/test_bip39_cross_language.py | 90 +++++-- tests/test_examples.py | 37 +++ 17 files changed, 777 insertions(+), 211 deletions(-) create mode 100644 examples/hdwallet/bips/from_entropy_slip39.py create mode 100644 tests/test_examples.py diff --git a/examples/hdwallet/bips/from_entropy.py b/examples/hdwallet/bips/from_entropy.py index f7f8263b..80ef1d0c 100644 --- a/examples/hdwallet/bips/from_entropy.py +++ b/examples/hdwallet/bips/from_entropy.py @@ -5,7 +5,7 @@ BIP39Entropy, BIP39_ENTROPY_STRENGTHS ) from hdwallet.mnemonics import BIP39_MNEMONIC_LANGUAGES -from hdwallet.cryptocurrencies import Qtum as Cryptocurrency +from hdwallet.cryptocurrencies import Bitcoin as Cryptocurrency from hdwallet.consts import PUBLIC_KEY_TYPES from hdwallet.derivations import ( BIP44Derivation, CHANGES diff --git a/examples/hdwallet/bips/from_entropy_slip39.py b/examples/hdwallet/bips/from_entropy_slip39.py new file mode 100644 index 00000000..ae9c413c --- /dev/null +++ b/examples/hdwallet/bips/from_entropy_slip39.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 + +from hdwallet import HDWallet +from hdwallet.entropies import ( + IEntropy, SLIP39Entropy, SLIP39_ENTROPY_STRENGTHS, BIP39Entropy, BIP39_ENTROPY_STRENGTHS, +) +from hdwallet.mnemonics import ( + IMnemonic, BIP39_MNEMONIC_LANGUAGES, BIP39Mnemonic, SLIP39Mnemonic +) +from hdwallet.seeds import (ISeed, SLIP39Seed) +from hdwallet.cryptocurrencies import Bitcoin as Cryptocurrency +from hdwallet.consts import PUBLIC_KEY_TYPES +from hdwallet.derivations import ( + BIP84Derivation, CHANGES +) +from hdwallet.hds import BIP84HD + +import json + +entropy_hex = "ffffffffffffffffffffffffffffffff" +entropy_bip39 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" + +slip39_entropy: IEntropy = SLIP39Entropy( + entropy=entropy_hex +) +slip39_mnemonic: IMnemonic = SLIP39Mnemonic( SLIP39Mnemonic.from_entropy( + entropy=slip39_entropy, language=BIP39_MNEMONIC_LANGUAGES.ENGLISH +)) +bip39_mnemonic: IMnemonic = BIP39Mnemonic( BIP39Mnemonic.from_entropy( + entropy=entropy_hex, language=BIP39_MNEMONIC_LANGUAGES.ENGLISH +)) +assert bip39_mnemonic.mnemonic() == entropy_bip39 + +slip39_seed: ISeed = SLIP39Seed( + seed=slip39_entropy.entropy() +) + +slip39_seed_recovered = SLIP39Seed.from_mnemonic(slip39_mnemonic) +assert slip39_seed_recovered == slip39_seed.seed() == slip39_entropy.entropy() + +# +# SLIP-39 can transport 128- or 256-bit entropy that is used DIRECTLY as an HD Wallet seed. +# +# This IS the standard way a Trezor SLIP-39 wallet recovery works, and produces the same wallets as +# if the Trezor hardware wallet was recovered using the SLIP-39 Mnemonics. +# +# SLIP-39 Entropy: ffffffffffffffffffffffffffffffff +# == 128-bit HD Wallet Seed +# +hdwallet: HDWallet = HDWallet( + cryptocurrency=Cryptocurrency, + hd=BIP84HD, + network=Cryptocurrency.NETWORKS.MAINNET, + language=BIP39_MNEMONIC_LANGUAGES.ENGLISH, + public_key_type=PUBLIC_KEY_TYPES.COMPRESSED, + passphrase="" +).from_seed( + seed=slip39_seed +) + +assert hdwallet.address() == "bc1q9yscq3l2yfxlvnlk3cszpqefparrv7tk24u6pl" +assert hdwallet.entropy() == None +assert hdwallet.seed() == "ffffffffffffffffffffffffffffffff" + + +# +# A SLIP-39 encoded 128- or 256-bit entropy can also be converted into BIP-39 entropy, and then into +# BIP-39 Mnemonics. This is NOT the normal Trezor SLIP-39 HD Wallet derivation; instead, it uses +# SLIP-39 to remember the original source entropy, and THEN encodes it into BIP-39 Mnemonics, and +# THEN uses mnemonics to recover the wallet via BIP-39 seed derivation. However, this IS the best +# approach to backing up BIP-39 Mnemonics via SLIP-39, as it retains the BIP-39 passphrase, and +# results in exactly the same HD Wallet derivations as if the original BIP-39 Mnemonics had been +# entered directly. +# +# SLIP-39 Entropy: ffffffffffffffffffffffffffffffff +# => BIP-39 Mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" +# => BIP-39 Seed: b6a6...25b6 +# == 512-bit HD Wallet Seed +# +hdwallet: HDWallet = HDWallet( + cryptocurrency=Cryptocurrency, + hd=BIP84HD, + network=Cryptocurrency.NETWORKS.MAINNET, + language=BIP39_MNEMONIC_LANGUAGES.ENGLISH, + public_key_type=PUBLIC_KEY_TYPES.COMPRESSED, + passphrase="" +).from_entropy( + entropy=slip39_entropy +) +assert hdwallet.address() == "bc1qk0a9hr7wjfxeenz9nwenw9flhq0tmsf6vsgnn2" +assert hdwallet.entropy() == "ffffffffffffffffffffffffffffffff" +assert hdwallet.seed() == "b6a6d8921942dd9806607ebc2750416b289adea669198769f2e15ed926c3aa92bf88ece232317b4ea463e84b0fcd3b53577812ee449ccc448eb45e6f544e25b6" \ + + +# To "back up" an existing BIP-39 Mnemonic phrase into multiple SLIP-39 cards, there are 2 ways: +# +# 1) Back up the BIP-39 Mnemonic's *input* 128- or 256-bit entropy +# a) decode the *input* entropy from the BIP-39 Mnemonic +# b) generate 20- or 33-word SLIP-39 Mnemonic(s) encoding the *input* entropy +# b1) optionally provide a SLIP-39 passphrase (not recommended) +# (later) +# c) recover the 128- or 256-bit entropy from 20- or 33-word SLIP-39 Mnemonic(s) +# c1) enter the SLIP-39 passphrase (not recommended) +# d) re-generate the BIP-39 Mnemonic from the entropy +# e) recover the wallet from BIP-39 Mnemonic generated Seed +# e1) enter the original BIP-39 passphrase +# +# 2) Back up the BIP-39 Mnemonic's *output* 512-bit seed +# a) generate the 512-bit BIP-39 Seed from the Mnemonic +# a1) enter the original BIP-39 passphrase +# b) generate 59-word SLIP-39 Mnemonic(s) encoding the output seed +# b1) optionally provide a SLIP-39 passphrase (not recommended) +# (later) +# c) recover the 512-bit seed from 59-word SLIP-39 Mnemonic(s) +# c1) enter the SLIP-39 passphrase (not recommended) +# d) recover the wallet from the 512-bit data as Seed +# d1) no BIP-39 passphrase required + +hdwallet: HDWallet = HDWallet( + cryptocurrency=Cryptocurrency, + hd=BIP84HD, + network=Cryptocurrency.NETWORKS.MAINNET, + language=BIP39_MNEMONIC_LANGUAGES.ENGLISH, + public_key_type=PUBLIC_KEY_TYPES.COMPRESSED, + passphrase="" +).from_mnemonic( + mnemonic=BIP39Mnemonic(mnemonic=entropy_bip39) +) +assert hdwallet.address() == "bc1qk0a9hr7wjfxeenz9nwenw9flhq0tmsf6vsgnn2" +assert hdwallet.entropy() == "ffffffffffffffffffffffffffffffff" +assert hdwallet.mnemonic() == entropy_bip39 +assert hdwallet.seed() \ + == "b6a6d8921942dd9806607ebc2750416b289adea669198769f2e15ed926c3aa92bf88ece232317b4ea463e84b0fcd3b53577812ee449ccc448eb45e6f544e25b6" + +# 1a-b) Decode and backup *input* entropy to SLIP-39 Mnemonics (size is 20 or 33 words based on input entropy size) +bip39_bu_1a_input_entropy = hdwallet.entropy() +bip39_bu_1b_input_slip39 = SLIP39Mnemonic.encode( + entropy=bip39_bu_1a_input_entropy, + language="Backup 4: One 1/1, Two 1/1, Fam 2/4, Fren 3/6", + passphrase="Don't use this", # 1b1) optional SLIP-39 passphrase - not well supported; leave empty + tabulate=True, +) +#print(f"{bip39_bu_1b_input_slip39}") + +# 1c-d) Recover *input* BIP-39 entropy from SLIP-39 +bip39_bu_1c_input_entropy = SLIP39Mnemonic.decode( + bip39_bu_1b_input_slip39, + passphrase="Don't use this", # 1c1) must match 1b1) - any passphrase is valid, produces different wallets +) +assert bip39_bu_1c_input_entropy == bip39_bu_1a_input_entropy + +# 1e) Recover BIP-39 wallet from SLIP-39 entropy (converts to BIP-39 mnemonic) +hdwallet: HDWallet = HDWallet( + cryptocurrency=Cryptocurrency, + hd=BIP84HD, + network=Cryptocurrency.NETWORKS.MAINNET, + language=BIP39_MNEMONIC_LANGUAGES.ENGLISH, + public_key_type=PUBLIC_KEY_TYPES.COMPRESSED, + passphrase="" +).from_entropy( + entropy=SLIP39Entropy(entropy=bip39_bu_1c_input_entropy) +) +assert hdwallet.address() == "bc1qk0a9hr7wjfxeenz9nwenw9flhq0tmsf6vsgnn2" +assert hdwallet.entropy() == "ffffffffffffffffffffffffffffffff" +assert hdwallet.mnemonic() == entropy_bip39 +assert hdwallet.seed() \ + == "b6a6d8921942dd9806607ebc2750416b289adea669198769f2e15ed926c3aa92bf88ece232317b4ea463e84b0fcd3b53577812ee449ccc448eb45e6f544e25b6" + +# 2a-b Recover *output* BIP-39 seed and backup to SLIP-39 Mnemonics (size is 59 based on 512-bit BIP-39 seed) +bip39_bu_2a_output_seed = hdwallet.seed() +bip39_bu_2b_output_slip39 = SLIP39Mnemonic.encode( + entropy=bip39_bu_2a_output_seed, + language="Backup 4: One 1/1, Two 1/1, Fam 2/4, Fren 3/6", + passphrase="Don't use this", # 2b1) optional SLIP-39 passphrase - not well supported; leave empty + tabulate=True, +) +#print(f"{bip39_bu_2b_output_slip39}") + +# 2c) Recover *output* BIP-39 seed from SLIP-39 +bip39_bu_2c_output_seed = SLIP39Mnemonic.decode( + bip39_bu_2b_output_slip39, + passphrase="Don't use this", # 2c1) must match 2b1) - any passphrase is valid, produces different wallets +) + +# 2d) recover the wallet from the 512-bit data as Seed +hdwallet: HDWallet = HDWallet( + cryptocurrency=Cryptocurrency, + hd=BIP84HD, + network=Cryptocurrency.NETWORKS.MAINNET, + language=BIP39_MNEMONIC_LANGUAGES.ENGLISH, + public_key_type=PUBLIC_KEY_TYPES.COMPRESSED, + passphrase="" +).from_seed( + seed=SLIP39Seed(seed=bip39_bu_2c_output_seed) +) +assert hdwallet.address() == "bc1qk0a9hr7wjfxeenz9nwenw9flhq0tmsf6vsgnn2" +assert hdwallet.entropy() == None +assert hdwallet.mnemonic() == None +assert hdwallet.seed() \ + == "b6a6d8921942dd9806607ebc2750416b289adea669198769f2e15ed926c3aa92bf88ece232317b4ea463e84b0fcd3b53577812ee449ccc448eb45e6f544e25b6" + + +#print(json.dumps(hdwallet.dumps(exclude={"indexes"}), indent=4, ensure_ascii=False)) + +# print("Cryptocurrency:", hdwallet.cryptocurrency()) +# print("Symbol:", hdwallet.symbol()) +# print("Network:", hdwallet.network()) +# print("Coin Type:", hdwallet.coin_type()) +# print("Entropy:", hdwallet.entropy()) +# print("Strength:", hdwallet.strength()) +# print("Mnemonic:", hdwallet.mnemonic()) +# print("Passphrase:", hdwallet.passphrase()) +# print("Language:", hdwallet.language()) +# print("Seed:", hdwallet.seed()) +# print("ECC:", hdwallet.ecc()) +# print("HD:", hdwallet.hd()) +# print("Semantic:", hdwallet.semantic()) +# print("Root XPrivate Key:", hdwallet.root_xprivate_key()) +# print("Root XPublic Key:", hdwallet.root_xpublic_key()) +# print("Root Private Key:", hdwallet.root_private_key()) +# print("Root WIF:", hdwallet.root_wif()) +# print("Root Chain Code:", hdwallet.root_chain_code()) +# print("Root Public Key:", hdwallet.root_public_key()) +# print("Strict:", hdwallet.strict()) +# print("Public Key Type:", hdwallet.public_key_type()) +# print("WIF Type:", hdwallet.wif_type()) +# print("Path:", hdwallet.path()) +# print("Depth:", hdwallet.depth()) +# print("Indexes:", hdwallet.indexes()) +# print("Index:", hdwallet.index()) +# print("XPrivate Key:", hdwallet.xprivate_key()) +# print("XPublic Key:", hdwallet.xpublic_key()) +# print("Private Key:", hdwallet.private_key()) +# print("WIF:", hdwallet.wif()) +# print("Chain Code:", hdwallet.chain_code()) +# print("Public Key:", hdwallet.public_key()) +# print("Uncompressed:", hdwallet.uncompressed()) +# print("Compressed:", hdwallet.compressed()) +# print("Hash:", hdwallet.hash()) +# print("Fingerprint:", hdwallet.fingerprint()) +# print("Parent Fingerprint:", hdwallet.parent_fingerprint()) +# print("Address:", hdwallet.address()) diff --git a/hdwallet/consts.py b/hdwallet/consts.py index c63ee175..33d4595f 100644 --- a/hdwallet/consts.py +++ b/hdwallet/consts.py @@ -14,9 +14,14 @@ class NestedNamespace(SimpleNamespace): + """Implements a NestedNamespace with support for sub-NestedNamespaces. + Processes the positional data in order, followed by any kwargs in order. As a result, the + __dict__ order reflects the order of the provided data and **kwargs. + + """ def __init__(self, data: Union[set, tuple, dict], **kwargs): - super().__init__(**kwargs) + super().__init__() if isinstance(data, set): for item in data: self.__setattr__(item, item) @@ -36,7 +41,11 @@ def __init__(self, data: Union[set, tuple, dict], **kwargs): self.__setattr__(key, NestedNamespace(value)) else: self.__setattr__(key, value) - + for key, value in kwargs.items(): + if isinstance(value, dict): + self.__setattr__(key, NestedNamespace(value)) + else: + self.__setattr__(key, value) class SLIP10_ED25519_CONST: """ diff --git a/hdwallet/cryptocurrencies/bitcoin.py b/hdwallet/cryptocurrencies/bitcoin.py index 4a3d23ae..21eafe96 100644 --- a/hdwallet/cryptocurrencies/bitcoin.py +++ b/hdwallet/cryptocurrencies/bitcoin.py @@ -111,7 +111,7 @@ class Bitcoin(ICryptocurrency): "BIP39", {"ELECTRUM_V1": "Electrum-V1"}, {"ELECTRUM_V2": "Electrum-V2"} )) SEEDS = Seeds(( - "BIP39", {"ELECTRUM_V1": "Electrum-V1"}, {"ELECTRUM_V2": "Electrum-V2"} + "BIP39", {"ELECTRUM_V1": "Electrum-V1"}, {"ELECTRUM_V2": "Electrum-V2"}, "SLIP39" )) HDS = HDs(( "BIP32", "BIP44", "BIP49", "BIP84", "BIP86", "BIP141", {"ELECTRUM_V1": "Electrum-V1"}, {"ELECTRUM_V2": "Electrum-V2"} diff --git a/hdwallet/cryptocurrencies/qtum.py b/hdwallet/cryptocurrencies/qtum.py index fd8f91df..3504fc35 100644 --- a/hdwallet/cryptocurrencies/qtum.py +++ b/hdwallet/cryptocurrencies/qtum.py @@ -99,10 +99,10 @@ class Qtum(ICryptocurrency): "BIP39" }) MNEMONICS = Mnemonics({ - "BIP39" + "BIP39", }) SEEDS = Seeds({ - "BIP39" + "BIP39", }) HDS = HDs({ "BIP32", "BIP44", "BIP49", "BIP84", "BIP86", "BIP141" diff --git a/hdwallet/hdwallet.py b/hdwallet/hdwallet.py index 1c78059a..abc32b90 100644 --- a/hdwallet/hdwallet.py +++ b/hdwallet/hdwallet.py @@ -277,8 +277,13 @@ def __init__( self._hd = hd(network=self._network.NAME) def from_entropy(self, entropy: IEntropy) -> "HDWallet": - """ - Initialize the HDWallet from entropy. + """Initialize the HDWallet from entropy. + + Supplying SLIP-39 encoded (or some other) entropy can be valid, if the entropy can be + converted to the native (first) entropy type of this cryptocurrency; normally, the size + would just have to be compatible. + + The entropy is converted to the corresponding Mnemonic type. :param entropy: The entropy source to generate the mnemonic. :type entropy: IEntropy @@ -295,6 +300,8 @@ def from_entropy(self, entropy: IEntropy) -> "HDWallet": +----------------+-----------------------------------------------------------------------------------------------------------+ | BIP's | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/bips/from_entropy.py | +----------------+-----------------------------------------------------------------------------------------------------------+ + | SLIP-39 | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/bips/from_entropy_slip39.py | + +----------------+-----------------------------------------------------------------------------------------------------------+ | Cardano | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/cardano/from_entropy.py | +----------------+-----------------------------------------------------------------------------------------------------------+ | Electrum-V1 | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/electrum/v1/from_entropy.py | @@ -303,10 +310,18 @@ def from_entropy(self, entropy: IEntropy) -> "HDWallet": +----------------+-----------------------------------------------------------------------------------------------------------+ | Monero | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/monero/from_entropy.py | +----------------+-----------------------------------------------------------------------------------------------------------+ + """ if entropy.name() not in self._cryptocurrency.ENTROPIES.get_entropies(): - raise Error(f"Invalid entropy class for {self._cryptocurrency.NAME} cryptocurrency") + try: + entropy = ENTROPIES.entropy( + name=self._cryptocurrency.ENTROPIES.get_entropies()[0] + ).__call__( + entropy=entropy.entropy() + ) + except Exception as exc: + raise Error(f"Invalid entropy class {entropy.name()} for {self._cryptocurrency.NAME} cryptocurrency") from exc self._entropy = entropy if self._entropy.name() == "Electrum-V2": @@ -330,6 +345,7 @@ def from_entropy(self, entropy: IEntropy) -> "HDWallet": mnemonic=mnemonic, mnemonic_type=self._mnemonic_type ) ) + return self.from_mnemonic( mnemonic=MNEMONICS.mnemonic( name=self._entropy.name() @@ -339,8 +355,11 @@ def from_entropy(self, entropy: IEntropy) -> "HDWallet": ) def from_mnemonic(self, mnemonic: IMnemonic) -> "HDWallet": - """ - Initialize the HDWallet from a mnemonic. + """Initialize the HDWallet from a mnemonic. + + Providing a different Mnemonic such as SLIP-39 is fine, so long as the entropy it encodes is + compatible with the native Mnemonic type of the cryptocurrency. Uses the default (first) + language of the default Mnemonic type. :param mnemonic: The mnemonic instance to generate the seed. :type mnemonic: IMnemonic @@ -365,10 +384,20 @@ def from_mnemonic(self, mnemonic: IMnemonic) -> "HDWallet": +----------------+-----------------------------------------------------------------------------------------------------------+ | Monero | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/monero/from_mnemonic.py | +----------------+-----------------------------------------------------------------------------------------------------------+ + """ if mnemonic.name() not in self._cryptocurrency.MNEMONICS.get_mnemonics(): - raise Error(f"Invalid mnemonic class for {self._cryptocurrency.NAME} cryptocurrency") + raise Error(f"Invalid mnemonic class {mnemonic.name()} for {self._cryptocurrency.NAME} cryptocurrency") + # try: + # mnemonic_cls = MNEMONICS.mnemonic( + # self._cryptocurrency.MNEMONICS.get_mnemonics()[0] + # ) + # mnemonic = mnemonic_cls.from_entropy( + # entropy=mnemonic.decode(mnemonic.mnemonic()), language=mnemonic_cls.languages[0] + # ) + # except Exception as exc: + # raise Error(f"Invalid mnemonic class {mnemonic.name()} for {self._cryptocurrency.NAME} cryptocurrency") from exc self._mnemonic = mnemonic if self._mnemonic.name() == "Electrum-V2": @@ -441,6 +470,8 @@ def from_seed(self, seed: ISeed) -> "HDWallet": +----------------+-----------------------------------------------------------------------------------------------------------+ | BIP's | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/bips/from_seed.py | +----------------+-----------------------------------------------------------------------------------------------------------+ + | SLIP-39 | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/bips/from_entropy_slip39.py | + +----------------+-----------------------------------------------------------------------------------------------------------+ | Cardano | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/cardano/from_seed.py | +----------------+-----------------------------------------------------------------------------------------------------------+ | Electrum-V1 | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/electrum/v1/from_seed.py | @@ -452,7 +483,7 @@ def from_seed(self, seed: ISeed) -> "HDWallet": """ if seed.name() not in self._cryptocurrency.SEEDS.get_seeds(): - raise Error(f"Invalid seed class for {self._cryptocurrency.NAME} cryptocurrency") + raise Error(f"Invalid seed class {seed.name()} for {self._cryptocurrency.NAME} cryptocurrency") self._seed = seed self._hd.from_seed( diff --git a/hdwallet/mnemonics/algorand/mnemonic.py b/hdwallet/mnemonics/algorand/mnemonic.py index f1f2cefe..10004323 100644 --- a/hdwallet/mnemonics/algorand/mnemonic.py +++ b/hdwallet/mnemonics/algorand/mnemonic.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Union, Dict, List, Optional + Union, Dict, List, Mapping, Optional ) from ...entropies import ( @@ -60,6 +60,7 @@ class AlgorandMnemonic(IMnemonic): """ checksum_length: int = 2 + words_list_number: int = 2048 words_list: List[int] = [ ALGORAND_MNEMONIC_WORDS.TWENTY_FIVE ] @@ -165,6 +166,8 @@ def decode( cls, mnemonic: str, language: Optional[str] = None, + words_list: Optional[List[str]] = None, + words_list_with_index: Optional[Mapping[str, int]] = None, **kwargs ) -> str: """ @@ -182,18 +185,41 @@ def decode( if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - words_list_with_index, language = cls.find_language(mnemonic=words, language=language) - word_indexes = [words_list_with_index[word] for word in words] - entropy_list: Optional[List[int]] = convert_bits(word_indexes[:-1], 11, 8) - assert entropy_list is not None - entropy: bytes = bytes(entropy_list)[:-1] - - checksum: bytes = sha512_256(entropy)[:cls.checksum_length] - checksum_word_indexes: Optional[List[int]] = convert_bits(checksum, 8, 11) - assert checksum_word_indexes is not None - if checksum_word_indexes[0] != word_indexes[-1]: - raise ChecksumError( - "Invalid checksum", expected=words_list_with_index.keys()[checksum_word_indexes[0]], got=words_list_with_index.keys()[word_indexes[-1]] - ) + candidates: Mapping[str, Mapping[str, int]] = cls.word_indices_candidates( + words=words, language=language, words_list=words_list, + words_list_with_index=words_list_with_index + ) - return bytes_to_string(entropy) + exception = None + entropies: Mapping[Optional[str], str] = {} + for language, word_indices in candidates.items(): + try: + word_indexes = [word_indices[word] for word in words] + entropy_list: Optional[List[int]] = convert_bits(word_indexes[:-1], 11, 8) + assert entropy_list is not None + entropy: bytes = bytes(entropy_list)[:-1] + + checksum: bytes = sha512_256(entropy)[:cls.checksum_length] + checksum_word_indexes: Optional[List[int]] = convert_bits(checksum, 8, 11) + assert checksum_word_indexes is not None + if checksum_word_indexes[0] != word_indexes[-1]: + raise ChecksumError( + "Invalid checksum", expected=words_list_with_index.keys()[checksum_word_indexes[0]], got=words_list_with_index.keys()[word_indexes[-1]] + ) + + entropies[language] = bytes_to_string(entropy) + + except Exception as exc: + # Collect first Exception; highest quality languages are first. + if exception is None: + exception = exc + + if entropies: + (candidate, entropy), *extras = entropies.items() + if extras: + exception = MnemonicError( + f"Ambiguous languages {', '.join(c for c, _ in extras)} or {candidate} for mnemonic; specify a preferred language" + ) + else: + return entropy + raise exception diff --git a/hdwallet/mnemonics/bip39/mnemonic.py b/hdwallet/mnemonics/bip39/mnemonic.py index 37f4d097..0808d4d7 100644 --- a/hdwallet/mnemonics/bip39/mnemonic.py +++ b/hdwallet/mnemonics/bip39/mnemonic.py @@ -278,47 +278,63 @@ def decode( if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - # May optionally provide a word<->index Mapping, or a language + words_list; if neither, the Mnemonic defaults are used. - if not words_list_with_index: - wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None - if words_list: - if not language: - raise Error( "Must provide language with words_list" ) - wordlist_path = { language: words_list } - words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) - if len(words_list_with_index) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list_with_index) - ) + # May optionally provide a word<->index Mapping, or a language + words_list; if neither, the + # Mnemonic defaults are used. Providing words_list_with_index avoids needing find_language. - mnemonic_bin: str = "".join(map( - lambda word: integer_to_binary_string( - words_list_with_index[word], cls.word_bit_length - ), words - )) - - mnemonic_bit_length: int = len(mnemonic_bin) - checksum_length: int = mnemonic_bit_length // 33 - checksum_bin: str = mnemonic_bin[-checksum_length:] - entropy: bytes = binary_string_to_bytes( - mnemonic_bin[:-checksum_length], checksum_length * 8 - ) - entropy_hash_bin: str = bytes_to_binary_string( - sha256(entropy), 32 * 8 + candidates: Mapping[str, Mapping[str, int]] = cls.word_indices_candidates( + words=words, language=language, words_list=words_list, + words_list_with_index=words_list_with_index ) - checksum_bin_got: str = entropy_hash_bin[:checksum_length] - if checksum_bin != checksum_bin_got: - raise ChecksumError( - "Invalid checksum", expected=checksum_bin, got=checksum_bin_got - ) - if checksum: - pad_bit_len: int = ( - mnemonic_bit_length - if mnemonic_bit_length % 8 == 0 else - mnemonic_bit_length + (8 - mnemonic_bit_length % 8) - ) - return bytes_to_string( - binary_string_to_bytes(mnemonic_bin, pad_bit_len // 4) - ) - return bytes_to_string(entropy) + # Ensure exactly one candidate language produces validated entropy + exception = None + entropies: Mapping[Optional[str], str] = {} + for language, word_indices in candidates.items(): + try: + mnemonic_bin: str = "".join(map( + lambda word: integer_to_binary_string( + word_indices[word], cls.word_bit_length + ), words + )) + + mnemonic_bit_length: int = len(mnemonic_bin) + checksum_length: int = mnemonic_bit_length // 33 + checksum_bin: str = mnemonic_bin[-checksum_length:] + entropy: bytes = binary_string_to_bytes( + mnemonic_bin[:-checksum_length], checksum_length * 8 + ) + entropy_hash_bin: str = bytes_to_binary_string( + sha256(entropy), 32 * 8 + ) + checksum_bin_got: str = entropy_hash_bin[:checksum_length] + if checksum_bin != checksum_bin_got: + raise ChecksumError( + f"Invalid {language or '(custom word list)'} checksum", expected=checksum_bin, got=checksum_bin_got + ) + + if checksum: + pad_bit_len: int = ( + mnemonic_bit_length + if mnemonic_bit_length % 8 == 0 else + mnemonic_bit_length + (8 - mnemonic_bit_length % 8) + ) + entropies[language] = bytes_to_string( + binary_string_to_bytes(mnemonic_bin, pad_bit_len // 4) + ) + else: + entropies[language] = bytes_to_string(entropy) + + except Exception as exc: + # Collect first Exception; highest quality languages are first. + if exception is None: + exception = exc + + if entropies: + (candidate, entropy), *extras = entropies.items() + if extras: + exception = MnemonicError( + f"Ambiguous languages {', '.join(c for c, _ in extras)} or {candidate} for mnemonic; specify a preferred language" + ) + else: + return entropy + raise exception diff --git a/hdwallet/mnemonics/electrum/v1/mnemonic.py b/hdwallet/mnemonics/electrum/v1/mnemonic.py index 1ce3f1e3..8d943b3a 100644 --- a/hdwallet/mnemonics/electrum/v1/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v1/mnemonic.py @@ -206,32 +206,43 @@ def decode( if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - # May optionally provide a word<->index Mapping, or a language + words_list; if neither, the Mnemonic defaults are used. - if not words_list_with_index: - wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None - if words_list: - if not language: - raise Error( "Must provide language with words_list" ) - wordlist_path = { language: words_list } - words_list_with_index, language = cls.find_language(mnemonic=words, language=language, wordlist_path=wordlist_path) - if len(words_list_with_index) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list) - ) - - entropy: bytes = b"" - for index in range(len(words) // 3): - word_1, word_2, word_3 = words[index * 3:(index * 3) + 3] - - word_1_index: int = words_list_with_index[word_1] - word_2_index: int = words_list_with_index[word_2] % cls.words_list_number - word_3_index: int = words_list_with_index[word_3] % cls.words_list_number - - chunk: int = ( - word_1_index + - (cls.words_list_number * ((word_2_index - word_1_index) % cls.words_list_number)) + - (cls.words_list_number * cls.words_list_number * ((word_3_index - word_2_index) % cls.words_list_number)) - ) - entropy += integer_to_bytes(chunk, bytes_num=4, endianness="big") + candidates: Mapping[str, Mapping[str, int]] = cls.word_indices_candidates( + words=words, language=language, words_list=words_list, + words_list_with_index=words_list_with_index + ) - return bytes_to_string(entropy) + exception = None + entropies: Mapping[Optional[str], str] = {} + for language, word_indices in candidates.items(): + try: + entropy: bytes = b"" + for index in range(len(words) // 3): + word_1, word_2, word_3 = words[index * 3:(index * 3) + 3] + + word_1_index: int = word_indices[word_1] + word_2_index: int = word_indices[word_2] % cls.words_list_number + word_3_index: int = word_indices[word_3] % cls.words_list_number + + chunk: int = ( + word_1_index + + (cls.words_list_number * ((word_2_index - word_1_index) % cls.words_list_number)) + + (cls.words_list_number * cls.words_list_number * ((word_3_index - word_2_index) % cls.words_list_number)) + ) + entropy += integer_to_bytes(chunk, bytes_num=4, endianness="big") + + entropies[language] = bytes_to_string(entropy) + + except Exception as exc: + # Collect first Exception; highest quality languages are first. + if exception is None: + exception = exc + + if entropies: + (candidate, entropy), *extras = entropies.items() + if extras: + exception = MnemonicError( + f"Ambiguous languages {', '.join(c for c, _ in extras)} or {candidate} for mnemonic; specify a preferred language" + ) + else: + return entropy + raise exception diff --git a/hdwallet/mnemonics/electrum/v2/mnemonic.py b/hdwallet/mnemonics/electrum/v2/mnemonic.py index 813de9eb..e3f14872 100644 --- a/hdwallet/mnemonics/electrum/v2/mnemonic.py +++ b/hdwallet/mnemonics/electrum/v2/mnemonic.py @@ -324,7 +324,9 @@ def decode( cls, mnemonic: str, language: Optional[str] = None, - mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.STANDARD + mnemonic_type: str = ELECTRUM_V2_MNEMONIC_TYPES.STANDARD, + words_list: Optional[List[str]] = None, + words_list_with_index: Optional[Mapping[str, int]] = None ) -> str: """ Decodes a mnemonic phrase into its original entropy value. @@ -345,17 +347,35 @@ def decode( if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - words_list_with_index, language = cls.find_language(mnemonic=words, language=language) - if len(words_list_with_index) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list_with_index) - ) - - entropy: int = 0 - for word in reversed(words): - entropy: int = (entropy * len(words_list_with_index)) + words_list_with_index[word] + candidates: Mapping[str, Mapping[str, int]] = cls.word_indices_candidates( + words=words, language=language, words_list=words_list, + words_list_with_index=words_list_with_index + ) - return bytes_to_string(integer_to_bytes(entropy)) + exception = None + entropies: Mapping[Optional[str], str] = {} + for language, word_indices in candidates.items(): + try: + entropy: int = 0 + for word in reversed(words): + entropy: int = (entropy * len(word_indices)) + word_indices[word] + + entropies[language] = bytes_to_string(integer_to_bytes(entropy)) + + except Exception as exc: + # Collect first Exception; highest quality languages are first. + if exception is None: + exception = exc + + if entropies: + (candidate, entropy), *extras = entropies.items() + if extras: + exception = MnemonicError( + f"Ambiguous languages {', '.join(c for c, _ in extras)} or {candidate} for mnemonic; specify a preferred language" + ) + else: + return entropy + raise exception @classmethod def is_valid( diff --git a/hdwallet/mnemonics/imnemonic.py b/hdwallet/mnemonics/imnemonic.py index 054d33e3..62eeeed9 100644 --- a/hdwallet/mnemonics/imnemonic.py +++ b/hdwallet/mnemonics/imnemonic.py @@ -18,6 +18,7 @@ import string import unicodedata from functools import lru_cache +from fractions import Fraction from collections import defaultdict @@ -581,13 +582,74 @@ def wordlist_indices( yield candidate, words_list, word_indices @classmethod - def find_language( + def rank_languages( cls, mnemonic: List[str], + language: Optional[str] = None, wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, + ) -> Generator[Tuple[int, Mapping[str, int], str], None, None]: + """Finds all languages that can satisfy the given mnemonic. + + Returns a sequence of their relative quality, and the Mapping of words/abbreviations to + indices, and the language. """ + + language_indices: Dict[str, Mapping[str, int]] = {} + quality: Dict[str, Fraction] = defaultdict(Fraction) # What ratio of canonical language symbols were matched + for candidate, words_list, words_indices in cls.wordlist_indices( wordlist_path=wordlist_path ): + language_indices[candidate] = words_indices + try: + # Check for exact matches and unique abbreviations, ensuring comparison occurs in + # composite "NFKC" normalized characters. + for word in mnemonic: + word_composed = unicodedata.normalize( "NFKC", word ) + try: + index = words_indices[word_composed] + except KeyError as ex: + if candidate in quality: + quality.pop(candidate) + raise MnemonicError(f"Unable to find word {word} in {candidate}") from ex + word_canonical = words_indices.keys()[index] + # The quality of a match is the ratio of symbols provided that exactly match, + # vs. total symbols in the canonical words. So, more abbreviations and missing + # symbols with Marks (accents) penalizes the candidate language. + len_exact = sum(c1 == c2 for c1, c2 in zip( word_composed, word_canonical )) + quality[candidate] += Fraction( len_exact, len( word_canonical )) + + if candidate == language: + # All words exactly matched word with or without accents, complete or uniquely + # abbreviated words in the preferred language! We're done - we don't need to + # test further candidate languages. + yield quality[candidate], words_indices, candidate + return + + # All words exactly matched words in this candidate language, or some words were + # found to be unique abbreviations of words in the candidate, but it isn't the + # preferred language (or no preferred language was specified). Keep track of its + # quality of match, but carry on testing other candidate languages. + except (MnemonicError, ValueError): + continue + + # No unambiguous match to any preferred language found (or no language matched all words). + if not quality: + raise MnemonicError(f"Invalid {cls.name()} mnemonic words") + + # Select the best available, of the potentially matching Mnemonics. Sort by the number of + # canonical symbols exactly matched (more is better - less ambiguous). However, unless we now test + # for is_valid, this would still be a statistical method, and thus still dangerous -- we should + # fail instead of returning a bad guess! + for ratio, candidate in sorted(((v, k) for k, v in quality.items()), reverse=True): + yield ratio, language_indices[candidate], candidate + + @classmethod + def find_language( + cls, + mnemonic: List[str], language: Optional[str] = None, + wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None, ) -> Tuple[Mapping[str, int], str]: - """Finds the language of the given mnemonic by checking against available word list(s), + """The traditional statistical method for deducing the language of a Mnemonic. + + Finds the language of the given mnemonic by checking against available word list(s), preferring the specified 'language' if supplied and exactly matches an available language. If a 'wordlist_path' dict of {language: path} is supplied, its languages are used. If a 'language' (optional) is supplied, any ambiguity is resolved by selecting the preferred @@ -599,17 +661,35 @@ def find_language( mnemonic is valid in multiple languages, either directly or as an abbreviation (or completely valid in both languages): - english: abandon about badge machine minute ozone salon science ... - french: abandon aboutir badge machine minute ozone salon science ... + english: abandon about badge machine minute ozone salon science ... + french: abandon aboutir badge machine minute ozone salon science ... + + or the classics, where the first is valid in both languages (due to abbreviations), + but only one language yields a BIP-39 Mnemonic that passes is_valid: + + Entropy == 00000000000000000000000000000000: + english: abandon abandon ... abandon about (valid) + french: abandon abandon ... abandon aboutir (invalid) + + Entropy == 00200400801002004008010020040080: + english abandon abandon ... abandon absurd (invalid) + french: abandon abandon ... abandon absurde (valid) + + or, completely ambiguous mnemonics that are totally valid in both languages, composed of + canonical words and passing internal checksums, but yielding different seeds, of course: + + essence capable figure noble distance fruit intact amateur surprise distance vague unique + lecture orange stable romance aspect junior fatal prison voyage globe village figure mobile badge usage social correct jaguar bonus science aspect question service crucial Clearly, it is /possible/ to specify a Mnemonic which for which it is impossible to uniquely determine the language! However, this Mnemonic would probably be encoding very poor entropy, so is quite unlikely to occur in a Mnemonic storing true entropy. But, it is - certainly possible (see above). However, especially with abbreviations, it is possible for - this to occur. For these Mnemonics, it is /impossible/ to know (or guess) which language - the Mnemonic was intended to be {en,de}coded with. Since an incorrect "guess" would lead to - a different seed and therefore different derived wallets -- a match to multiple languages - with the same quality and with no preferred 'language' leads to an Exception. + certainly possible (see above); especially with abbreviations. + + For these Mnemonics, it is /impossible/ to know (or guess) which language the Mnemonic was + intended to be {en,de}coded with. Since an incorrect "guess" would lead to a different seed + and therefore different derived wallets -- a match to multiple languages with the same + quality ranking and with no preferred 'language' raises an Exception. Even the final word (which encodes some checksum bits) cannot determine the language with finality, because it is only a statistical checksum! For 128-bit 12-word encodings, only 4 @@ -622,6 +702,9 @@ def find_language( recognizing the wrong language for some Mnemonic, and therefore producing the wrong derived cryptographic keys. + Furthermore, for implementing .decode, it is recommended that you use .rank_languages, and + actually attempt to decode each matching language, raising an Exception unless there is + exactly one mnemonic language found that passes validity checks. The returned Mapping[str, int] contains all accepted word -> index mappings, including all acceptable abbreviations, with and without character accents. This is typically the @@ -634,56 +717,57 @@ def find_language( :param language: The preferred language, used if valid and mnemonic matches. :type mnemonic: Optional[str] - :return: A tuple containing the language's word indices and the language name. - :rtype: Tuple[[str, int], str] + :return: A tuple containing the matching language's quality ratio, word indices and language name. + :rtype: Tuple[Fraction, Mapping[str, int], str] """ - language_indices: Dict[str, Mapping[str, int]] = {} - quality: Dict[str, int] = defaultdict(int) # How many language symbols were matched - for candidate, words_list, words_indices in cls.wordlist_indices( wordlist_path=wordlist_path ): - language_indices[candidate] = words_indices - try: - # Check for exact matches and unique abbreviations, ensuring comparison occurs in - # composite "NFKC" normalized characters. - for word in mnemonic: - word_composed = unicodedata.normalize( "NFKC", word ) - try: - index = words_indices[word_composed] - except KeyError as ex: - if candidate in quality: - quality.pop(candidate) - raise MnemonicError(f"Unable to find word {word} in {candidate}") from ex - word_canonical = words_indices.keys()[index] - quality[candidate] += len( word_canonical ) + (ratio, word_indices, candidate), *worse = cls.rank_languages( mnemonic, language=language, wordlist_path=wordlist_path ) + + if worse and ratio == worse[0][0]: + # There are more than one matching candidate languages -- and they are both equivalent + # in quality. We cannot know (or guess) the language with any certainty. + raise MnemonicError(f"Ambiguous languages {', '.join(c for _r, _w, c in worse)} or {candidate} for mnemonic; specify a preferred language") - if candidate == language: - # All words exactly matched word with or without accents, complete or uniquely - # abbreviated words in the preferred language! We're done - we don't need to - # test further candidate languages. - return words_indices, candidate + return word_indices, candidate - # All words exactly matched words in this candidate language, or some words were - # found to be unique abbreviations of words in the candidate, but it isn't the - # preferred language (or no preferred language was specified). Keep track of its - # quality of match, but carry on testing other candidate languages. - except (MnemonicError, ValueError): - continue + @classmethod + def word_indices_candidates( + cls, + words: List[str], # normalized mnemonic words + language: Optional[str], # required, if words_list provided + words_list: Optional[List[str]] = None, + words_list_with_index: Optional[Mapping[str, int]] = None, + ) -> Mapping[str, Mapping[str, int]]: + """Collect candidate language(s) and their word_indices. - # No unambiguous match to any preferred language found (or no language matched all words). - if not quality: - raise MnemonicError(f"Invalid {cls.name()} mnemonic words") + Uses .rank_languages to determine all the candidate languages that may match the mnemonic. - # Select the best available. Sort by the number of characters matched (more is better - - # less ambiguous). This is a statistical method; it is still dangerous, and we should fail - # instead of returning a bad guess! - (candidate, matches), *worse = sorted(quality.items(), key=lambda k_v: k_v[1], reverse=True ) - if worse and matches == worse[0][1]: - # There are more than one matching candidate languages -- and they are both equivalent - # in quality. We cannot know (or guess) the language with any certainty. - raise MnemonicError(f"Ambiguous languages {', '.join(c for c, w in worse)} or {candidate} for mnemonic; specify a preferred language") + Raises Exceptions on word_indices that don't match cls.words_list_number, so it must be + defined for each IMnemonic-derived class that uses this. - return language_indices[candidate], candidate + """ + + candidates: Mapping[str, Mapping[str, int]] = {} + if words_list_with_index: + candidates[language] = words_list_with_index + else: + wordlist_path: Optional[Dict[str, Union[str, List[str]]]] = None + if words_list: + if not language: + raise Error( "Must provide language with words_list" ) + wordlist_path = { language: words_list } + for _rank, word_indices, language in cls.rank_languages( + mnemonic=words, language=language, wordlist_path=wordlist_path + ): + candidates[language] = word_indices + assert candidates # rank_languages will always return at least one + indices_lens = set( map( len, candidates.values() )) + if indices_lens != {cls.words_list_number}: + raise Error( + "Invalid number of loaded words list", expected=cls.words_list_number, got=indices_lens + ) + return candidates @classmethod def collect( @@ -726,7 +810,12 @@ def collect( symbol = yield (set(updaters) - complete, terminal, possible) @classmethod - def is_valid(cls, mnemonic: Union[str, List[str]], language: Optional[str] = None, **kwargs) -> bool: + def is_valid( + cls, + mnemonic: Union[str, List[str]], + language: Optional[str] = None, + **kwargs + ) -> bool: """Checks if the given mnemonic is valid. Catches mnemonic-validity related or word indexing Exceptions and returns False, but lets diff --git a/hdwallet/mnemonics/monero/mnemonic.py b/hdwallet/mnemonics/monero/mnemonic.py index b881449e..e3919811 100644 --- a/hdwallet/mnemonics/monero/mnemonic.py +++ b/hdwallet/mnemonics/monero/mnemonic.py @@ -6,7 +6,7 @@ import unicodedata from typing import ( - Union, Dict, List, Optional + Union, Dict, List, Mapping, Optional ) from ...entropies import ( @@ -262,6 +262,8 @@ def decode( cls, mnemonic: str, language: Optional[str] = None, + words_list: Optional[List[str]] = None, + words_list_with_index: Optional[Mapping[str, int]] = None, **kwargs ) -> str: """ @@ -281,28 +283,45 @@ def decode( if len(words) not in cls.words_list: raise MnemonicError("Invalid mnemonic words count", expected=cls.words_list, got=len(words)) - words_list_with_index, language = cls.find_language(mnemonic=words, language=language) - if len(words_list_with_index) != cls.words_list_number: - raise Error( - "Invalid number of loaded words list", expected=cls.words_list_number, got=len(words_list_with_index) - ) + candidates: Mapping[str, Mapping[str, int]] = cls.word_indices_candidates( + words=words, language=language, words_list=words_list, + words_list_with_index=words_list_with_index + ) - if len(words) in cls.words_checksum: - mnemonic: list = words[:-1] - unique_prefix_length = cls.language_unique_prefix_lengths[language] - prefixes = "".join(unicodedata.normalize("NFD", word)[:unique_prefix_length] for word in mnemonic) - checksum_word = mnemonic[ - bytes_to_integer(crc32(prefixes)) % len(mnemonic) - ] - if words[-1] != checksum_word: - raise ChecksumError( - "Invalid checksum", expected=checksum_word, got=words[-1] + exception = None + entropies: Mapping[Optional[str], str] = {} + for language, word_indices in candidates.items(): + try: + if len(words) in cls.words_checksum: + mnemonic: list = words[:-1] + unique_prefix_length = cls.language_unique_prefix_lengths[language] + prefixes = "".join(unicodedata.normalize("NFD", word)[:unique_prefix_length] for word in mnemonic) + checksum_word = mnemonic[ + bytes_to_integer(crc32(prefixes)) % len(mnemonic) + ] + if words[-1] != checksum_word: + raise ChecksumError( + "Invalid checksum", expected=checksum_word, got=words[-1] + ) + + entropy: bytes = b"" + for index in range(len(words) // 3): + word_1, word_2, word_3 = words[index * 3:(index * 3) + 3] + entropy += words_to_bytes_chunk( + word_1, word_2, word_3, word_indices.keys(), "little" + ) + entropies[language] = bytes_to_string(entropy) + except Exception as exc: + # Collect first Exception; highest quality languages are first. + if exception is None: + exception = exc + + if entropies: + (candidate, entropy), *extras = entropies.items() + if extras: + exception = MnemonicError( + f"Ambiguous languages {', '.join(c for c, _ in extras)} or {candidate} for mnemonic; specify a preferred language" ) - - entropy: bytes = b"" - for index in range(len(words) // 3): - word_1, word_2, word_3 = words[index * 3:(index * 3) + 3] - entropy += words_to_bytes_chunk( - word_1, word_2, word_3, words_list_with_index.keys(), "little" - ) - return bytes_to_string(entropy) + else: + return entropy + raise exception diff --git a/hdwallet/mnemonics/slip39/mnemonic.py b/hdwallet/mnemonics/slip39/mnemonic.py index 4494b05e..a35de75c 100644 --- a/hdwallet/mnemonics/slip39/mnemonic.py +++ b/hdwallet/mnemonics/slip39/mnemonic.py @@ -502,7 +502,7 @@ def encode( passphrase: str = "", extendable: bool = True, iteration_exponent: int = 1, - tabulate: bool = False, # False disables; any other value causes prefixing/columnization + tabulate: Optional[Union[bool, int]] = False, # False disables; other values control prefixing/columnization ) -> str: """Encodes entropy into a SLIP-39 mnemonic phrase according to the specified language. @@ -569,7 +569,10 @@ def encode( @classmethod def decode( - cls, mnemonic: str, passphrase: str = "", language: Optional[str] = None, + cls, + mnemonic: str, + passphrase: str = "", + language: Optional[str] = None, ) -> str: """Decodes SLIP-39 mnemonic phrases into its corresponding entropy. diff --git a/hdwallet/seeds/iseed.py b/hdwallet/seeds/iseed.py index 628dd9aa..ce26298d 100644 --- a/hdwallet/seeds/iseed.py +++ b/hdwallet/seeds/iseed.py @@ -58,11 +58,6 @@ def seed(self) -> str: """ Retrieves the seed associated with the current instance. - :param mnemonic: The mnemonic phrase to be decoded. Can be a string or an instance of `IMnemonic`. - :type mnemonic: Union[str, IMnemonic] - :param language: The preferred language, if known - :type language: Optional[str] - :return: The seed as a string. :rtype: str """ @@ -72,4 +67,15 @@ def seed(self) -> str: @classmethod @abstractmethod def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], language: Optional[str], **kwargs) -> str: + """ + Retrieves the seed associated with the Mnemonic. + + :param mnemonic: The mnemonic phrase to be decoded. Can be a string or an instance of `IMnemonic`. + :type mnemonic: Union[str, IMnemonic] + :param language: The preferred language, if known + :type language: Optional[str] + + :return: The seed as a string. + :rtype: str + """ pass diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index f444f094..3094a798 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -39,7 +39,12 @@ def name(cls) -> str: return "SLIP39" @classmethod - def from_mnemonic(cls, mnemonic: Union[str, IMnemonic], passphrase: Optional[str] = None, language: Optional[str] = None) -> str: + def from_mnemonic( + cls, + mnemonic: Union[str, IMnemonic], + passphrase: Optional[str] = None, + language: Optional[str] = None, + ) -> str: """Converts a mnemonic phrase to its corresponding raw entropy. The Mnemonic representation for SLIP-39 seeds is simple hex, and must be of the supported diff --git a/tests/test_bip39_cross_language.py b/tests/test_bip39_cross_language.py index a6736153..6c0d6169 100644 --- a/tests/test_bip39_cross_language.py +++ b/tests/test_bip39_cross_language.py @@ -69,9 +69,9 @@ def setup_class(cls, languages: list[str] = None): for language, words, indices in BIP39Mnemonic.wordlist_indices(): language_data[language] = dict( indices = indices, - words = set( indices.keys() ), - unique = set( indices.unique() ), - abbrevs = set( indices.abbreviations() ), + words = set( indices.keys() ), # canonical words + unique = set( indices.unique() ), # unique words with/without UTF-8 Marks + abbrevs = set( indices.abbreviations() ), # unique abbreviations ) if language not in languages: continue @@ -84,10 +84,12 @@ def setup_class(cls, languages: list[str] = None): # Find common words across all languages - only process requested languages requested_data = {lang: language_data[lang] for lang in languages if lang in language_data} - all_word_sets = [data['unique'] for data in requested_data.values()] + all_word_sets = [data['words'] for data in requested_data.values()] + all_unique_sets = [data['unique'] for data in requested_data.values()] all_abbrev_lists = [data['abbrevs'] for data in requested_data.values()] cls.common_words = set.intersection(*all_word_sets) if all_word_sets else set() + cls.common_unique = set.intersection(*all_unique_sets) if all_unique_sets else set() cls.common_abbrevs = set.intersection(*all_abbrev_lists) if all_abbrev_lists else set() # Print statistics. Given that UTF-8 marks may or may not be supplied, there may be more @@ -95,8 +97,10 @@ def setup_class(cls, languages: list[str] = None): for lang, data in requested_data.items(): print(f"{lang.capitalize()} UTF-8 words base: {len(data['words'])} unique: {len(data['unique'])}, abbreviations: {len(data['abbrevs'])}") - print(f"Common words found: {len(cls.common_words)}") - print(f"First 20 common words: {sorted(cls.common_words)[:20]}") + print(f"Common canonical words found: {len(cls.common_words)}") + print(f"First 20 common canonical words: {sorted(cls.common_words)[:20]}") + print(f"Common unique words found: {len(cls.common_unique)}") + print(f"First 20 common unique words: {sorted(cls.common_unique)[:20]}") print(f"Common abbrevs found: {len(cls.common_abbrevs)}") print(f"First 20 common abbrevs: {sorted(cls.common_abbrevs)[:20]}") @@ -120,7 +124,7 @@ def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_att successful_english: int = 0 for _ in range(total_attempts): try: - # Generate a random N-word mnemonic from common words + # Generate a random N-word mnemonic from common canonical words mnemonic = self.create_random_mnemonic_from_common_words(words) # Try to decode as both English and French - both must succeed (pass checksum) @@ -156,32 +160,52 @@ def dual_language_N_word_mnemonics(self, words=12, expected_rate=1/16, total_att def test_cross_language_12_word_mnemonics(self): """Test 12-word mnemonics that work in both English and French. - For example: + For example, these match in terms of unique words (missing UTF-8 marks): 'ocean correct rival double theme village crucial veteran salon tunnel question minute' 'puzzle mobile video pelican bicycle ocean effort train junior brave effort theme' 'elegant cruel science guide fortune nation humble lecture ozone dragon question village' 'innocent prison romance jaguar voyage depart fruit crucial video salon reunion fatigue' 'position dragon correct question figure notable service vague civil public distance emotion' + But completely ambiguous mnemonics in multiple languages (no UTF-8 Marks to make one + slightly less of a match) are somewhat rare, but *certainly* not unlikely: + 'essence capable figure noble distance fruit intact amateur surprise distance vague unique' + 'nature crucial aspect mobile nation muscle surface usage valve concert impact label' + 'animal double noble volume innocent fatigue abandon minute panda vague label stable' + 'surface client simple junior volume palace amateur brave surprise bonus talent million' + """ - with pytest.raises(MnemonicError, match="Ambiguous languages french or english"): - BIP39Mnemonic.decode( - 'ocean correct rival double theme village crucial veteran salon tunnel question minute' - ) + # Make sure trying to decode a completely ambiguous BIP-39 Mnemonic reports as Ambiguous, + # but providing a preferred language works + ambiguous = 'essence capable figure noble distance fruit intact amateur surprise distance vague unique' + with pytest.raises(MnemonicError, match="Ambiguous languages"): + BIP39Mnemonic.decode( mnemonic=ambiguous ) + assert BIP39Mnemonic.decode( mnemonic=ambiguous, language="french" ) == "5aa5219a52a47adc20e858e528f7d5f9" + assert BIP39Mnemonic.decode( mnemonic=ambiguous, language="english" ) == "4d443d584ad3fabb9d603dda67f7c2f6" + candidates = self.dual_language_N_word_mnemonics(words=12, expected_rate=1/16) def test_cross_language_24_word_mnemonics(self): """Test 24-word mnemonics that work in both English and French. - For example: + For example (ambiguous in unique words): 'pelican pelican minute intact science figure vague civil badge rival pizza fatal sphere nation simple ozone canal talent emotion wagon ozone valve voyage angle' 'pizza intact noble fragile piece suspect legal badge vital guide coyote volume nature wagon badge festival danger train desert intact opinion veteran romance metal' + + Totally ambiguous in canonical words: + 'lecture orange stable romance aspect junior fatal prison voyage globe village figure mobile badge usage social correct jaguar bonus science aspect question service crucial' + 'aspect loyal stable bonus label question effort virus digital fruit junior nature abandon concert crucial brave double aspect capable figure orange unique unique machine' """ - with pytest.raises(MnemonicError, match="Ambiguous languages french or english"): - BIP39Mnemonic.decode( - 'pelican pelican minute intact science figure vague civil badge rival pizza fatal' - ' sphere nation simple ozone canal talent emotion wagon ozone valve voyage angle' - ) + # Make sure trying to decode a completely ambiguous BIP-39 Mnemonic reports as Ambiguous, + # but providing a preferred language works + ambiguous = ( + 'lecture orange stable romance aspect junior fatal prison voyage globe village figure' + ' mobile badge usage social correct jaguar bonus science aspect question service crucial' + ) + with pytest.raises(MnemonicError, match="Ambiguous languages"): + BIP39Mnemonic.decode( mnemonic=ambiguous ) + assert BIP39Mnemonic.decode( mnemonic=ambiguous, language="french" ) == "8c75af88e8a1291158de0cfeee5fec3349d62fbd16f13870b8836bf1298b36e1" + assert BIP39Mnemonic.decode( mnemonic=ambiguous, language="english" ) == "7f137b4f5dc0d6f254e558f60c6bd02b08e622fbe66f308ee465e070d75f3109" candidates = self.dual_language_N_word_mnemonics(words=24, expected_rate=1/256) @@ -459,7 +483,32 @@ def test_ambiguous_languages(self): This test verifies that when a mnemonic contains words common to multiple languages with equal quality scores, find_language raises a MnemonicError indicating the ambiguity. + + """ + # Try some problematic ones; not completely ambiguous, but guessing based on symbol matching + # fails, while validating all candidate languages succeeds: + for problem, language, entropy in [ + ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "english", # guessed by .find_language; correct! + "00000000000000000000000000000000", + ), + ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon absurd", + "english", # guessed by .find_language; wrong! + "00200400801002004008010020040080", # but .decode finds correct french entropy by checksum + ), + ]: + # for rank, indices, candidate in BIP39Mnemonic.rank_languages( BIP39Mnemonic.normalize( problem )): + # print( f"{str(rank):12} == {float(rank):8.4f} {candidate}" ) + _indices, guessed = BIP39Mnemonic.find_language( BIP39Mnemonic.normalize( problem )) + assert guessed == language, \ + f"Wrong language {guessed} for: {problem}" + recovered = BIP39Mnemonic.decode( problem ) + assert recovered == entropy, \ + f"Wrong entropy {recovered} for: {problem}" + from hdwallet.exceptions import MnemonicError # Create a test mnemonic using only common words between languages @@ -480,7 +529,8 @@ def test_ambiguous_languages(self): # This is the expected behavior for truly ambiguous mnemonics #assert "Ambiguous languages" in str(e), f"Expected ambiguity error, got: {e}" #assert "specify a preferred language" in str(e), f"Expected preference suggestion, got: {e}" - print(f"✓ Correctly detected ambiguous mnemonic: {e}") + #print(f"✓ Correctly detected ambiguous mnemonic: {e}") + pass # Test 2: Verify that specifying a preferred language resolves the ambiguity # Try with each available language that contains these common words @@ -549,6 +599,8 @@ def test_bip39_collection(): + + def test_bip39_korean(): # Confirm that UTF-8 Mark handling works in other languages (particularly Korean) (_, korean_nfc, korean_indices), = BIP39Mnemonic.wordlist_indices( diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 00000000..3ed047bf --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import pytest +from pathlib import Path + + +# Discover all example scripts +EXAMPLES_DIR = Path(__file__).parent.parent / "examples" +EXAMPLE_SCRIPTS = sorted(EXAMPLES_DIR.rglob("*.py")) + +# Project root directory (for PYTHONPATH) +PROJECT_ROOT = Path(__file__).parent.parent + + +@pytest.mark.examples +@pytest.mark.parametrize("script_path", EXAMPLE_SCRIPTS, ids=lambda p: str(p.relative_to(EXAMPLES_DIR))) +def test_example_script_runs(script_path): + """Test that example scripts execute without raising exceptions.""" + # Set PYTHONPATH to use local source instead of installed package + env = os.environ.copy() + env["PYTHONPATH"] = str(PROJECT_ROOT) + + result = subprocess.run( + ["python3", str(script_path)], + capture_output=True, + text=True, + timeout=30, + env=env + ) + + assert result.returncode == 0, ( + f"Script {script_path.name} failed with exit code {result.returncode}\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) From 6bdd84c4c0be4a9020ba6487e986f5fd4e3b4739 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 27 Oct 2025 13:10:16 +0400 Subject: [PATCH 36/38] Upgrade tabulate to tabulate-slip39 for downstream projects --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1e3555c8..a7de3f81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,4 @@ pynacl>=1.5.0,<2 base58>=2.1.1,<3 cbor2>=5.6.1,<6 shamir-mnemonic-slip39>=0.4,<0.5 -tabulate>=0.9.0,<1 +tabulate-slip39>=0.10.6,<1 From 51723f8588f23610b0af61d6354ae5307f17ea14 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Thu, 30 Oct 2025 09:19:18 +0100 Subject: [PATCH 37/38] Correct Seed validation o Make ISeed validation much more robust, detecting and fixing many instances of incorrect ISeed handling throughout. o Cardano seed type handling transmits cardano_type correctly o Make SLIP39 an alternative Seed type for most cryptocurrencies o Make cryptocurrency.SEEDS an ordered NestedNamespace, so we can reliably deduce the "default" ISeed type for each Cryptocurrency. o Run all clients/ scripts during pytest --- .gitignore | 2 +- clients/brave_legacy.py | 30 ++++++- clients/cardano/byron_icarus.py | 2 +- clients/cardano/byron_ledger.py | 2 +- clients/cardano/byron_legacy.py | 2 +- clients/cardano/shelley_icarus.py | 2 +- clients/cardano/shelley_ledger.py | 2 +- examples/hdwallet/algorand/from_seed.py | 2 +- examples/hdwallet/cardano/from_entropy.py | 2 +- examples/hdwallet/cardano/from_seed.py | 3 +- examples/seeds/cardano.py | 4 +- hdwallet/cli/dump.py | 17 +++- hdwallet/cli/dumps.py | 17 +++- hdwallet/cli/generate/seed.py | 3 +- hdwallet/consts.py | 2 +- hdwallet/cryptocurrencies/adcoin.py | 6 +- hdwallet/cryptocurrencies/akashnetwork.py | 6 +- hdwallet/cryptocurrencies/algorand.py | 2 +- hdwallet/cryptocurrencies/anon.py | 6 +- hdwallet/cryptocurrencies/aptos.py | 6 +- hdwallet/cryptocurrencies/arbitum.py | 6 +- hdwallet/cryptocurrencies/argoneum.py | 6 +- hdwallet/cryptocurrencies/artax.py | 6 +- hdwallet/cryptocurrencies/aryacoin.py | 6 +- hdwallet/cryptocurrencies/asiacoin.py | 6 +- hdwallet/cryptocurrencies/auroracoin.py | 6 +- hdwallet/cryptocurrencies/avalanche.py | 6 +- hdwallet/cryptocurrencies/avian.py | 6 +- hdwallet/cryptocurrencies/axe.py | 6 +- hdwallet/cryptocurrencies/axelar.py | 6 +- hdwallet/cryptocurrencies/bandprotocol.py | 6 +- hdwallet/cryptocurrencies/base.py | 6 +- hdwallet/cryptocurrencies/bata.py | 6 +- hdwallet/cryptocurrencies/beetlecoin.py | 6 +- hdwallet/cryptocurrencies/belacoin.py | 6 +- hdwallet/cryptocurrencies/binance.py | 6 +- hdwallet/cryptocurrencies/bitcloud.py | 6 +- hdwallet/cryptocurrencies/bitcoinatom.py | 6 +- hdwallet/cryptocurrencies/bitcoincash.py | 6 +- hdwallet/cryptocurrencies/bitcoincashslp.py | 6 +- hdwallet/cryptocurrencies/bitcoingold.py | 6 +- hdwallet/cryptocurrencies/bitcoingreen.py | 6 +- hdwallet/cryptocurrencies/bitcoinplus.py | 6 +- hdwallet/cryptocurrencies/bitcoinprivate.py | 6 +- hdwallet/cryptocurrencies/bitcoinsv.py | 6 +- hdwallet/cryptocurrencies/bitcoinz.py | 6 +- hdwallet/cryptocurrencies/bitcore.py | 6 +- hdwallet/cryptocurrencies/bitsend.py | 6 +- hdwallet/cryptocurrencies/blackcoin.py | 6 +- hdwallet/cryptocurrencies/blocknode.py | 6 +- hdwallet/cryptocurrencies/blockstamp.py | 6 +- hdwallet/cryptocurrencies/bolivarcoin.py | 6 +- hdwallet/cryptocurrencies/britcoin.py | 6 +- hdwallet/cryptocurrencies/canadaecoin.py | 6 +- hdwallet/cryptocurrencies/cannacoin.py | 6 +- hdwallet/cryptocurrencies/celo.py | 6 +- hdwallet/cryptocurrencies/chihuahua.py | 6 +- hdwallet/cryptocurrencies/clams.py | 6 +- hdwallet/cryptocurrencies/clubcoin.py | 6 +- hdwallet/cryptocurrencies/compcoin.py | 6 +- hdwallet/cryptocurrencies/cosmos.py | 6 +- hdwallet/cryptocurrencies/cpuchain.py | 6 +- hdwallet/cryptocurrencies/cranepay.py | 6 +- hdwallet/cryptocurrencies/crave.py | 6 +- hdwallet/cryptocurrencies/dash.py | 6 +- hdwallet/cryptocurrencies/deeponion.py | 6 +- hdwallet/cryptocurrencies/defcoin.py | 6 +- hdwallet/cryptocurrencies/denarius.py | 6 +- hdwallet/cryptocurrencies/diamond.py | 6 +- hdwallet/cryptocurrencies/digibyte.py | 6 +- hdwallet/cryptocurrencies/digitalcoin.py | 6 +- hdwallet/cryptocurrencies/divi.py | 6 +- hdwallet/cryptocurrencies/dogecoin.py | 6 +- hdwallet/cryptocurrencies/dydx.py | 6 +- hdwallet/cryptocurrencies/ecash.py | 6 +- hdwallet/cryptocurrencies/ecoin.py | 6 +- hdwallet/cryptocurrencies/edrcoin.py | 6 +- hdwallet/cryptocurrencies/egulden.py | 6 +- hdwallet/cryptocurrencies/einsteinium.py | 6 +- hdwallet/cryptocurrencies/elastos.py | 6 +- hdwallet/cryptocurrencies/energi.py | 6 +- hdwallet/cryptocurrencies/eos.py | 6 +- hdwallet/cryptocurrencies/ergo.py | 6 +- hdwallet/cryptocurrencies/ethereum.py | 6 +- hdwallet/cryptocurrencies/europecoin.py | 6 +- hdwallet/cryptocurrencies/evrmore.py | 6 +- hdwallet/cryptocurrencies/exclusivecoin.py | 6 +- hdwallet/cryptocurrencies/fantom.py | 6 +- hdwallet/cryptocurrencies/feathercoin.py | 6 +- hdwallet/cryptocurrencies/fetchai.py | 6 +- hdwallet/cryptocurrencies/filecoin.py | 6 +- hdwallet/cryptocurrencies/firo.py | 6 +- hdwallet/cryptocurrencies/firstcoin.py | 6 +- hdwallet/cryptocurrencies/fix.py | 6 +- hdwallet/cryptocurrencies/flashcoin.py | 6 +- hdwallet/cryptocurrencies/flux.py | 6 +- hdwallet/cryptocurrencies/foxdcoin.py | 6 +- hdwallet/cryptocurrencies/fujicoin.py | 6 +- hdwallet/cryptocurrencies/gamecredits.py | 6 +- hdwallet/cryptocurrencies/gcrcoin.py | 6 +- hdwallet/cryptocurrencies/gobyte.py | 6 +- hdwallet/cryptocurrencies/gridcoin.py | 6 +- hdwallet/cryptocurrencies/groestlcoin.py | 6 +- hdwallet/cryptocurrencies/gulden.py | 6 +- hdwallet/cryptocurrencies/harmony.py | 6 +- hdwallet/cryptocurrencies/helleniccoin.py | 6 +- hdwallet/cryptocurrencies/hempcoin.py | 6 +- hdwallet/cryptocurrencies/horizen.py | 6 +- hdwallet/cryptocurrencies/huobitoken.py | 6 +- hdwallet/cryptocurrencies/hush.py | 6 +- hdwallet/cryptocurrencies/icon.py | 6 +- hdwallet/cryptocurrencies/injective.py | 6 +- hdwallet/cryptocurrencies/insanecoin.py | 6 +- hdwallet/cryptocurrencies/internetofpeople.py | 6 +- hdwallet/cryptocurrencies/irisnet.py | 6 +- hdwallet/cryptocurrencies/ixcoin.py | 6 +- hdwallet/cryptocurrencies/jumbucks.py | 6 +- hdwallet/cryptocurrencies/kava.py | 6 +- hdwallet/cryptocurrencies/kobocoin.py | 6 +- hdwallet/cryptocurrencies/komodo.py | 6 +- hdwallet/cryptocurrencies/landcoin.py | 6 +- hdwallet/cryptocurrencies/lbrycredits.py | 6 +- hdwallet/cryptocurrencies/linx.py | 6 +- hdwallet/cryptocurrencies/litecoin.py | 6 +- hdwallet/cryptocurrencies/litecoincash.py | 6 +- hdwallet/cryptocurrencies/litecoinz.py | 6 +- hdwallet/cryptocurrencies/lkrcoin.py | 6 +- hdwallet/cryptocurrencies/lynx.py | 6 +- hdwallet/cryptocurrencies/mazacoin.py | 6 +- hdwallet/cryptocurrencies/megacoin.py | 6 +- hdwallet/cryptocurrencies/metis.py | 6 +- hdwallet/cryptocurrencies/minexcoin.py | 6 +- hdwallet/cryptocurrencies/monacoin.py | 6 +- hdwallet/cryptocurrencies/monero.py | 2 +- hdwallet/cryptocurrencies/monk.py | 6 +- hdwallet/cryptocurrencies/multiversx.py | 6 +- hdwallet/cryptocurrencies/myriadcoin.py | 6 +- hdwallet/cryptocurrencies/namecoin.py | 6 +- hdwallet/cryptocurrencies/nano.py | 6 +- hdwallet/cryptocurrencies/navcoin.py | 6 +- hdwallet/cryptocurrencies/near.py | 6 +- hdwallet/cryptocurrencies/neblio.py | 6 +- hdwallet/cryptocurrencies/neo.py | 6 +- hdwallet/cryptocurrencies/neoscoin.py | 6 +- hdwallet/cryptocurrencies/neurocoin.py | 6 +- hdwallet/cryptocurrencies/neutron.py | 6 +- hdwallet/cryptocurrencies/newyorkcoin.py | 6 +- hdwallet/cryptocurrencies/ninechronicles.py | 6 +- hdwallet/cryptocurrencies/nix.py | 6 +- hdwallet/cryptocurrencies/novacoin.py | 6 +- hdwallet/cryptocurrencies/nubits.py | 6 +- hdwallet/cryptocurrencies/nushares.py | 6 +- hdwallet/cryptocurrencies/okcash.py | 6 +- hdwallet/cryptocurrencies/oktchain.py | 6 +- hdwallet/cryptocurrencies/omni.py | 6 +- hdwallet/cryptocurrencies/onix.py | 6 +- hdwallet/cryptocurrencies/ontology.py | 6 +- hdwallet/cryptocurrencies/optimism.py | 6 +- hdwallet/cryptocurrencies/osmosis.py | 6 +- hdwallet/cryptocurrencies/particl.py | 6 +- hdwallet/cryptocurrencies/peercoin.py | 6 +- hdwallet/cryptocurrencies/pesobit.py | 6 +- hdwallet/cryptocurrencies/phore.py | 6 +- hdwallet/cryptocurrencies/pinetwork.py | 6 +- hdwallet/cryptocurrencies/pinkcoin.py | 6 +- hdwallet/cryptocurrencies/pivx.py | 6 +- hdwallet/cryptocurrencies/polygon.py | 6 +- hdwallet/cryptocurrencies/poswcoin.py | 6 +- hdwallet/cryptocurrencies/potcoin.py | 6 +- hdwallet/cryptocurrencies/projectcoin.py | 6 +- hdwallet/cryptocurrencies/putincoin.py | 6 +- hdwallet/cryptocurrencies/qtum.py | 6 +- hdwallet/cryptocurrencies/rapids.py | 6 +- hdwallet/cryptocurrencies/ravencoin.py | 6 +- hdwallet/cryptocurrencies/reddcoin.py | 6 +- hdwallet/cryptocurrencies/ripple.py | 6 +- hdwallet/cryptocurrencies/ritocoin.py | 6 +- hdwallet/cryptocurrencies/rsk.py | 6 +- hdwallet/cryptocurrencies/rubycoin.py | 6 +- hdwallet/cryptocurrencies/safecoin.py | 6 +- hdwallet/cryptocurrencies/saluscoin.py | 6 +- hdwallet/cryptocurrencies/scribe.py | 6 +- hdwallet/cryptocurrencies/secret.py | 6 +- hdwallet/cryptocurrencies/shadowcash.py | 6 +- hdwallet/cryptocurrencies/shentu.py | 6 +- hdwallet/cryptocurrencies/slimcoin.py | 6 +- hdwallet/cryptocurrencies/smileycoin.py | 6 +- hdwallet/cryptocurrencies/solana.py | 6 +- hdwallet/cryptocurrencies/solarcoin.py | 6 +- hdwallet/cryptocurrencies/stafi.py | 6 +- hdwallet/cryptocurrencies/stash.py | 6 +- hdwallet/cryptocurrencies/stellar.py | 6 +- hdwallet/cryptocurrencies/stratis.py | 6 +- hdwallet/cryptocurrencies/sugarchain.py | 6 +- hdwallet/cryptocurrencies/sui.py | 6 +- hdwallet/cryptocurrencies/syscoin.py | 6 +- hdwallet/cryptocurrencies/terra.py | 6 +- hdwallet/cryptocurrencies/tezos.py | 6 +- hdwallet/cryptocurrencies/theta.py | 6 +- hdwallet/cryptocurrencies/thoughtai.py | 6 +- hdwallet/cryptocurrencies/toacoin.py | 6 +- hdwallet/cryptocurrencies/tron.py | 6 +- hdwallet/cryptocurrencies/twins.py | 6 +- .../cryptocurrencies/ultimatesecurecash.py | 6 +- hdwallet/cryptocurrencies/unobtanium.py | 6 +- hdwallet/cryptocurrencies/vcash.py | 6 +- hdwallet/cryptocurrencies/vechain.py | 6 +- hdwallet/cryptocurrencies/verge.py | 6 +- hdwallet/cryptocurrencies/vertcoin.py | 6 +- hdwallet/cryptocurrencies/viacoin.py | 6 +- hdwallet/cryptocurrencies/vivo.py | 6 +- hdwallet/cryptocurrencies/voxels.py | 6 +- hdwallet/cryptocurrencies/vpncoin.py | 6 +- hdwallet/cryptocurrencies/wagerr.py | 6 +- hdwallet/cryptocurrencies/whitecoin.py | 6 +- hdwallet/cryptocurrencies/wincoin.py | 6 +- hdwallet/cryptocurrencies/xinfin.py | 6 +- hdwallet/cryptocurrencies/xuez.py | 6 +- hdwallet/cryptocurrencies/ycash.py | 6 +- hdwallet/cryptocurrencies/zcash.py | 6 +- hdwallet/cryptocurrencies/zclassic.py | 6 +- hdwallet/cryptocurrencies/zetacoin.py | 6 +- hdwallet/cryptocurrencies/zilliqa.py | 6 +- hdwallet/cryptocurrencies/zoobc.py | 6 +- hdwallet/entropies/ientropy.py | 10 ++- hdwallet/hdwallet.py | 50 +++++++++--- hdwallet/seeds/algorand.py | 6 +- hdwallet/seeds/bip39.py | 4 +- hdwallet/seeds/cardano.py | 81 ++++++++++--------- hdwallet/seeds/electrum/v1.py | 6 +- hdwallet/seeds/electrum/v2.py | 4 +- hdwallet/seeds/iseed.py | 34 +++++--- hdwallet/seeds/monero.py | 8 +- hdwallet/seeds/slip39.py | 4 +- tests/data/json/hdwallet.json | 80 +++++++++++++++++- .../test_cardano_byron_icarus_from_seed.py | 3 +- .../test_cardano_byron_ledger_from_seed.py | 3 +- .../test_cardano_byron_legacy_from_seed.py | 3 +- .../test_cardano_shelley_icarus_from_seed.py | 3 +- .../test_cardano_shelley_ledger_from_seed.py | 3 +- tests/test_clients.py | 37 +++++++++ 241 files changed, 954 insertions(+), 723 deletions(-) create mode 100644 tests/test_clients.py diff --git a/.gitignore b/.gitignore index 4d23741c..8dac3a38 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ experiment/ # Setuptools stuff build/ dist/ -hdwallet.egg-info/ +hdwallet*.egg-info/ # Python stuff __pycache__/ diff --git a/clients/brave_legacy.py b/clients/brave_legacy.py index 13611d66..9baa6c11 100644 --- a/clients/brave_legacy.py +++ b/clients/brave_legacy.py @@ -2,7 +2,7 @@ from hdwallet import HDWallet from hdwallet.mnemonics import BIP39Mnemonic -from hdwallet.seeds import BIP39Seed +from hdwallet.seeds import BIP39Seed, SLIP39Seed from hdwallet.cryptocurrencies import Ethereum as Cryptocurrency from hdwallet.hds import BIP44HD from hdwallet.derivations import ( @@ -15,14 +15,14 @@ MNEMONIC: str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " \ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" -# Initialize Ethereum HDWallet +# Initialize Ethereum HDWallet using BIP-39 Entropy as Seed hdwallet: HDWallet = HDWallet( cryptocurrency=Cryptocurrency, hd=BIP44HD, network=Cryptocurrency.NETWORKS.MAINNET, passphrase=None ).from_seed( # Get Ethereum HDWallet from seed - seed=BIP39Seed( + seed=SLIP39Seed( seed=BIP39Mnemonic.decode(mnemonic=MNEMONIC) # Use decoded mnemonic (entropy) directly as seed ) ).from_derivation( # Drive from BIP44 derivation @@ -36,4 +36,28 @@ # Same address of Brave crypto wallets extension # print(json.dumps(hdwallet.dump(exclude={"indexes"}), indent=4, ensure_ascii=False)) +print(f"Path: {hdwallet.path()}") print(f"Address: {hdwallet.address()}") + +assert hdwallet.address() == "0xACA6302EcBde40120cb8A08361D8BD461282Bd18" + +# Initialize Ethereum HDWallet using BIP-39 Seed (confirmed current Brave Wallet behavior) +hdwallet: HDWallet = HDWallet( + cryptocurrency=Cryptocurrency, + hd=BIP44HD, + network=Cryptocurrency.NETWORKS.MAINNET, + passphrase=None +).from_seed( # Get Ethereum HDWallet from seed + seed=BIP39Seed.from_mnemonic(mnemonic=MNEMONIC) # Use BIP-39 encoded mnemonic as seed +).from_derivation( # Drive from BIP44 derivation + derivation=BIP44Derivation( + coin_type=Cryptocurrency.COIN_TYPE, + account=0, + change=CHANGES.EXTERNAL_CHAIN, + address=0 + ) +) + +# print(json.dumps(hdwallet.dump(exclude={"indexes"}), indent=4, ensure_ascii=False)) +print(f"Address: {hdwallet.address()}") +assert hdwallet.address() == "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb" diff --git a/clients/cardano/byron_icarus.py b/clients/cardano/byron_icarus.py index d926d087..1566bb10 100644 --- a/clients/cardano/byron_icarus.py +++ b/clients/cardano/byron_icarus.py @@ -38,7 +38,7 @@ seed: str = CardanoSeed.from_mnemonic( mnemonic=bip39_mnemonic, cardano_type=Cardano.TYPES.BYRON_ICARUS ) -cardano_seed: CardanoSeed = CardanoSeed(seed=seed) +cardano_seed: CardanoSeed = CardanoSeed(seed=seed, cardano_type=Cardano.TYPES.BYRON_ICARUS) # Update Byron-Icarus Cardano HD root keys from seed cardano_hd.from_seed( diff --git a/clients/cardano/byron_ledger.py b/clients/cardano/byron_ledger.py index beec179d..a37ea6b2 100644 --- a/clients/cardano/byron_ledger.py +++ b/clients/cardano/byron_ledger.py @@ -38,7 +38,7 @@ seed: str = CardanoSeed.from_mnemonic( mnemonic=bip39_mnemonic, cardano_type=Cardano.TYPES.BYRON_LEDGER ) -cardano_seed: CardanoSeed = CardanoSeed(seed=seed) +cardano_seed: CardanoSeed = CardanoSeed(seed=seed, cardano_type=Cardano.TYPES.BYRON_LEDGER) # Update Byron-Ledger Cardano HD root keys from seed cardano_hd.from_seed( diff --git a/clients/cardano/byron_legacy.py b/clients/cardano/byron_legacy.py index af370928..382705f8 100644 --- a/clients/cardano/byron_legacy.py +++ b/clients/cardano/byron_legacy.py @@ -38,7 +38,7 @@ seed: str = CardanoSeed.from_mnemonic( mnemonic=bip39_mnemonic, cardano_type=Cardano.TYPES.BYRON_LEGACY ) -cardano_seed: CardanoSeed = CardanoSeed(seed=seed) +cardano_seed: CardanoSeed = CardanoSeed(seed=seed, cardano_type=Cardano.TYPES.BYRON_LEGACY) # Update Byron-Legacy Cardano HD root keys from seed cardano_hd.from_seed( diff --git a/clients/cardano/shelley_icarus.py b/clients/cardano/shelley_icarus.py index 0a4702bb..cceafe64 100644 --- a/clients/cardano/shelley_icarus.py +++ b/clients/cardano/shelley_icarus.py @@ -39,7 +39,7 @@ seed: str = CardanoSeed.from_mnemonic( mnemonic=bip39_mnemonic, cardano_type=Cardano.TYPES.SHELLEY_ICARUS ) -cardano_seed: CardanoSeed = CardanoSeed(seed=seed) +cardano_seed: CardanoSeed = CardanoSeed(seed=seed, cardano_type=Cardano.TYPES.SHELLEY_ICARUS) # Update Shelley-Icarus Cardano HD root keys from seed cardano_hd.from_seed( diff --git a/clients/cardano/shelley_ledger.py b/clients/cardano/shelley_ledger.py index b1248621..a13f31ac 100644 --- a/clients/cardano/shelley_ledger.py +++ b/clients/cardano/shelley_ledger.py @@ -39,7 +39,7 @@ seed: str = CardanoSeed.from_mnemonic( mnemonic=bip39_mnemonic, cardano_type=Cardano.TYPES.SHELLEY_LEDGER ) -cardano_seed: CardanoSeed = CardanoSeed(seed=seed) +cardano_seed: CardanoSeed = CardanoSeed(seed=seed, cardano_type=Cardano.TYPES.SHELLEY_LEDGER) # Update Shelley-Ledger Cardano HD root keys from seed cardano_hd.from_seed( diff --git a/examples/hdwallet/algorand/from_seed.py b/examples/hdwallet/algorand/from_seed.py index 948482db..6f937f12 100644 --- a/examples/hdwallet/algorand/from_seed.py +++ b/examples/hdwallet/algorand/from_seed.py @@ -14,7 +14,7 @@ hd=AlgorandHD ).from_seed( seed=AlgorandSeed( - seed="fca87b68fdffa968895901c894f678f6" + seed="a27436e742dafe27428b84925d4be6a1c40856d14dc73d54431a94bd6b95264b" ) ).from_derivation( derivation=CustomDerivation( diff --git a/examples/hdwallet/cardano/from_entropy.py b/examples/hdwallet/cardano/from_entropy.py index 54f2fdf3..d786717f 100644 --- a/examples/hdwallet/cardano/from_entropy.py +++ b/examples/hdwallet/cardano/from_entropy.py @@ -24,7 +24,7 @@ ).from_entropy( entropy=BIP39Entropy( entropy=BIP39Entropy.generate( - strength=BIP39_ENTROPY_STRENGTHS.TWO_HUNDRED_TWENTY_FOUR + strength=BIP39_ENTROPY_STRENGTHS.ONE_HUNDRED_TWENTY_EIGHT ) ) ).from_derivation( diff --git a/examples/hdwallet/cardano/from_seed.py b/examples/hdwallet/cardano/from_seed.py index aa919267..f2e034d5 100644 --- a/examples/hdwallet/cardano/from_seed.py +++ b/examples/hdwallet/cardano/from_seed.py @@ -20,7 +20,8 @@ passphrase="talonlab" ).from_seed( seed=CardanoSeed( - seed="fca87b68fdffa968895901c894f678f6" + seed="fca87b68fdffa968895901c894f678f6", + cardano_type=Cryptocurrency.TYPES.SHELLEY_ICARUS, ) ).from_derivation( derivation=CIP1852Derivation( diff --git a/examples/seeds/cardano.py b/examples/seeds/cardano.py index 5a19e777..10436fce 100644 --- a/examples/seeds/cardano.py +++ b/examples/seeds/cardano.py @@ -47,8 +47,8 @@ CardanoSeedClass: Type[ISeed] = SEEDS.seed(data["name"]) for seed in data["seeds"]: - cardano_seed_class = CardanoSeedClass(seed["seed"]) - cardano_seed = CardanoSeed(seed["seed"]) + cardano_seed_class = CardanoSeedClass(seed["seed"], cardano_type=seed['cardano_type']) + cardano_seed = CardanoSeed(seed["seed"], cardano_type=seed['cardano_type']) # Always provide passphrase=None if not present, like TS passphrase = seed.get("passphrase", None) diff --git a/hdwallet/cli/dump.py b/hdwallet/cli/dump.py index 80802f4e..179b2582 100644 --- a/hdwallet/cli/dump.py +++ b/hdwallet/cli/dump.py @@ -110,11 +110,20 @@ def dump(**kwargs) -> None: f"Wrong seed client, (expected={SEEDS.names()}, got='{kwargs.get('seed_client')}')" ), err=True) sys.exit() - hdwallet.from_seed( - seed=SEEDS.seed(name=kwargs.get("seed_client")).__call__( - seed=kwargs.get("seed") + if kwargs.get("seed_client") == "Cardano" and kwargs.get("cardano_type"): + # If a specific cardano_type is specified, we must override the CardanoSeed default + hdwallet.from_seed( + seed=SEEDS.seed(name=kwargs.get("seed_client")).__call__( + seed=kwargs.get("seed"), + cardano_type=kwargs.get("cardano_type") + ) + ) + else: + hdwallet.from_seed( + seed=SEEDS.seed(name=kwargs.get("seed_client")).__call__( + seed=kwargs.get("seed") + ) ) - ) elif kwargs.get("xprivate_key"): hdwallet.from_xprivate_key( xprivate_key=kwargs.get("xprivate_key"), diff --git a/hdwallet/cli/dumps.py b/hdwallet/cli/dumps.py index b28f40f0..f9e3557c 100644 --- a/hdwallet/cli/dumps.py +++ b/hdwallet/cli/dumps.py @@ -116,11 +116,20 @@ def dumps(**kwargs) -> None: f"Wrong seed client, (expected={SEEDS.names()}, got='{kwargs.get('seed_client')}')" ), err=True) sys.exit() - hdwallet.from_seed( - seed=SEEDS.seed(name=kwargs.get("seed_client")).__call__( - seed=kwargs.get("seed") + if kwargs.get("seed_client") == "Cardano" and kwargs.get("cardano_type"): + # If a specific cardano_type is specified, we must override the CardanoSeed default + hdwallet.from_seed( + seed=SEEDS.seed(name=kwargs.get("seed_client")).__call__( + seed=kwargs.get("seed"), + cardano_type=kwargs.get("cardano_type") + ) + ) + else: + hdwallet.from_seed( + seed=SEEDS.seed(name=kwargs.get("seed_client")).__call__( + seed=kwargs.get("seed") + ) ) - ) elif kwargs.get("xprivate_key"): hdwallet.from_xprivate_key( xprivate_key=kwargs.get("xprivate_key"), diff --git a/hdwallet/cli/generate/seed.py b/hdwallet/cli/generate/seed.py index 2ccd5ce1..9270173c 100644 --- a/hdwallet/cli/generate/seed.py +++ b/hdwallet/cli/generate/seed.py @@ -61,7 +61,8 @@ def generate_seed(**kwargs) -> None: passphrase=kwargs.get("passphrase"), language=kwargs.get("language"), cardano_type=kwargs.get("cardano_type") - ) + ), + cardano_type=kwargs.get("cardano_type") ) elif kwargs.get("client") == ElectrumV2Seed.name(): seed: ISeed = ElectrumV2Seed( diff --git a/hdwallet/consts.py b/hdwallet/consts.py index 33d4595f..e802f300 100644 --- a/hdwallet/consts.py +++ b/hdwallet/consts.py @@ -17,7 +17,7 @@ class NestedNamespace(SimpleNamespace): """Implements a NestedNamespace with support for sub-NestedNamespaces. Processes the positional data in order, followed by any kwargs in order. As a result, the - __dict__ order reflects the order of the provided data and **kwargs. + __dict__ order reflects the order of the provided data and **kwargs (if they are ordered). """ def __init__(self, data: Union[set, tuple, dict], **kwargs): diff --git a/hdwallet/cryptocurrencies/adcoin.py b/hdwallet/cryptocurrencies/adcoin.py index b9feb141..565314da 100644 --- a/hdwallet/cryptocurrencies/adcoin.py +++ b/hdwallet/cryptocurrencies/adcoin.py @@ -55,9 +55,9 @@ class Adcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/akashnetwork.py b/hdwallet/cryptocurrencies/akashnetwork.py index 20d8e184..ac5dcca0 100644 --- a/hdwallet/cryptocurrencies/akashnetwork.py +++ b/hdwallet/cryptocurrencies/akashnetwork.py @@ -50,9 +50,9 @@ class AkashNetwork(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/algorand.py b/hdwallet/cryptocurrencies/algorand.py index e24c10c4..d46252d1 100644 --- a/hdwallet/cryptocurrencies/algorand.py +++ b/hdwallet/cryptocurrencies/algorand.py @@ -51,7 +51,7 @@ class Algorand(ICryptocurrency): {"ALGORAND": "Algorand"}, "BIP39" )) SEEDS = Seeds(( - {"ALGORAND": "Algorand"}, "BIP39" + {"ALGORAND": "Algorand"}, "BIP39", "SLIP39" )) HDS = HDs(( {"ALGORAND": "Algorand"}, "BIP32", "BIP44" diff --git a/hdwallet/cryptocurrencies/anon.py b/hdwallet/cryptocurrencies/anon.py index 465c3aba..27502623 100644 --- a/hdwallet/cryptocurrencies/anon.py +++ b/hdwallet/cryptocurrencies/anon.py @@ -55,9 +55,9 @@ class Anon(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/aptos.py b/hdwallet/cryptocurrencies/aptos.py index 2df4dbcc..1dc49aef 100644 --- a/hdwallet/cryptocurrencies/aptos.py +++ b/hdwallet/cryptocurrencies/aptos.py @@ -49,9 +49,9 @@ class Aptos(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/arbitum.py b/hdwallet/cryptocurrencies/arbitum.py index 51b3816a..1f3a4b66 100644 --- a/hdwallet/cryptocurrencies/arbitum.py +++ b/hdwallet/cryptocurrencies/arbitum.py @@ -50,9 +50,9 @@ class Arbitrum(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/argoneum.py b/hdwallet/cryptocurrencies/argoneum.py index 2700d43f..e022d9d3 100644 --- a/hdwallet/cryptocurrencies/argoneum.py +++ b/hdwallet/cryptocurrencies/argoneum.py @@ -54,9 +54,9 @@ class Argoneum(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/artax.py b/hdwallet/cryptocurrencies/artax.py index a3d33ab1..cd113cb8 100644 --- a/hdwallet/cryptocurrencies/artax.py +++ b/hdwallet/cryptocurrencies/artax.py @@ -54,9 +54,9 @@ class Artax(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/aryacoin.py b/hdwallet/cryptocurrencies/aryacoin.py index dda8b2c3..d7e44834 100644 --- a/hdwallet/cryptocurrencies/aryacoin.py +++ b/hdwallet/cryptocurrencies/aryacoin.py @@ -54,9 +54,9 @@ class Aryacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/asiacoin.py b/hdwallet/cryptocurrencies/asiacoin.py index 2694f104..7360792a 100644 --- a/hdwallet/cryptocurrencies/asiacoin.py +++ b/hdwallet/cryptocurrencies/asiacoin.py @@ -53,9 +53,9 @@ class Asiacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/auroracoin.py b/hdwallet/cryptocurrencies/auroracoin.py index 405172fc..17cf4ce5 100644 --- a/hdwallet/cryptocurrencies/auroracoin.py +++ b/hdwallet/cryptocurrencies/auroracoin.py @@ -54,9 +54,9 @@ class Auroracoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/avalanche.py b/hdwallet/cryptocurrencies/avalanche.py index 08ab28c5..960456d4 100644 --- a/hdwallet/cryptocurrencies/avalanche.py +++ b/hdwallet/cryptocurrencies/avalanche.py @@ -52,9 +52,9 @@ class Avalanche(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/avian.py b/hdwallet/cryptocurrencies/avian.py index eb014757..fa340359 100644 --- a/hdwallet/cryptocurrencies/avian.py +++ b/hdwallet/cryptocurrencies/avian.py @@ -68,9 +68,9 @@ class Avian(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/axe.py b/hdwallet/cryptocurrencies/axe.py index acd36795..2785c27f 100644 --- a/hdwallet/cryptocurrencies/axe.py +++ b/hdwallet/cryptocurrencies/axe.py @@ -54,9 +54,9 @@ class Axe(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/axelar.py b/hdwallet/cryptocurrencies/axelar.py index feeef7d0..8198ebd6 100644 --- a/hdwallet/cryptocurrencies/axelar.py +++ b/hdwallet/cryptocurrencies/axelar.py @@ -52,9 +52,9 @@ class Axelar(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bandprotocol.py b/hdwallet/cryptocurrencies/bandprotocol.py index 3d534f8c..5c307662 100644 --- a/hdwallet/cryptocurrencies/bandprotocol.py +++ b/hdwallet/cryptocurrencies/bandprotocol.py @@ -51,9 +51,9 @@ class BandProtocol(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/base.py b/hdwallet/cryptocurrencies/base.py index 000092a5..f17d8863 100644 --- a/hdwallet/cryptocurrencies/base.py +++ b/hdwallet/cryptocurrencies/base.py @@ -50,9 +50,9 @@ class Base(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bata.py b/hdwallet/cryptocurrencies/bata.py index 56566491..16406345 100644 --- a/hdwallet/cryptocurrencies/bata.py +++ b/hdwallet/cryptocurrencies/bata.py @@ -56,9 +56,9 @@ class Bata(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/beetlecoin.py b/hdwallet/cryptocurrencies/beetlecoin.py index 077603c6..7e1d1a11 100644 --- a/hdwallet/cryptocurrencies/beetlecoin.py +++ b/hdwallet/cryptocurrencies/beetlecoin.py @@ -54,9 +54,9 @@ class BeetleCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/belacoin.py b/hdwallet/cryptocurrencies/belacoin.py index 72a1cb80..c2f6076e 100644 --- a/hdwallet/cryptocurrencies/belacoin.py +++ b/hdwallet/cryptocurrencies/belacoin.py @@ -55,9 +55,9 @@ class BelaCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/binance.py b/hdwallet/cryptocurrencies/binance.py index a5f8ea41..a3157ebc 100644 --- a/hdwallet/cryptocurrencies/binance.py +++ b/hdwallet/cryptocurrencies/binance.py @@ -51,9 +51,9 @@ class Binance(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcloud.py b/hdwallet/cryptocurrencies/bitcloud.py index 3da1e0cd..3e0908b6 100644 --- a/hdwallet/cryptocurrencies/bitcloud.py +++ b/hdwallet/cryptocurrencies/bitcloud.py @@ -55,9 +55,9 @@ class BitCloud(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoinatom.py b/hdwallet/cryptocurrencies/bitcoinatom.py index 24361bee..b4662ba5 100644 --- a/hdwallet/cryptocurrencies/bitcoinatom.py +++ b/hdwallet/cryptocurrencies/bitcoinatom.py @@ -63,9 +63,9 @@ class BitcoinAtom(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoincash.py b/hdwallet/cryptocurrencies/bitcoincash.py index c5e91c07..8087282a 100644 --- a/hdwallet/cryptocurrencies/bitcoincash.py +++ b/hdwallet/cryptocurrencies/bitcoincash.py @@ -105,9 +105,9 @@ class BitcoinCash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoincashslp.py b/hdwallet/cryptocurrencies/bitcoincashslp.py index 6a7887a4..ba9ea69c 100644 --- a/hdwallet/cryptocurrencies/bitcoincashslp.py +++ b/hdwallet/cryptocurrencies/bitcoincashslp.py @@ -100,9 +100,9 @@ class BitcoinCashSLP(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoingold.py b/hdwallet/cryptocurrencies/bitcoingold.py index 442f9b65..c6d6f8fd 100644 --- a/hdwallet/cryptocurrencies/bitcoingold.py +++ b/hdwallet/cryptocurrencies/bitcoingold.py @@ -68,9 +68,9 @@ class BitcoinGold(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoingreen.py b/hdwallet/cryptocurrencies/bitcoingreen.py index 3df76787..b1b4e549 100644 --- a/hdwallet/cryptocurrencies/bitcoingreen.py +++ b/hdwallet/cryptocurrencies/bitcoingreen.py @@ -54,9 +54,9 @@ class BitcoinGreen(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoinplus.py b/hdwallet/cryptocurrencies/bitcoinplus.py index 9730bad6..aad557cc 100644 --- a/hdwallet/cryptocurrencies/bitcoinplus.py +++ b/hdwallet/cryptocurrencies/bitcoinplus.py @@ -55,9 +55,9 @@ class BitcoinPlus(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoinprivate.py b/hdwallet/cryptocurrencies/bitcoinprivate.py index 7b76842d..bb0950ea 100644 --- a/hdwallet/cryptocurrencies/bitcoinprivate.py +++ b/hdwallet/cryptocurrencies/bitcoinprivate.py @@ -72,9 +72,9 @@ class BitcoinPrivate(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoinsv.py b/hdwallet/cryptocurrencies/bitcoinsv.py index a602eb51..3f10f4bf 100644 --- a/hdwallet/cryptocurrencies/bitcoinsv.py +++ b/hdwallet/cryptocurrencies/bitcoinsv.py @@ -54,9 +54,9 @@ class BitcoinSV(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcoinz.py b/hdwallet/cryptocurrencies/bitcoinz.py index f30fcaf8..1eb3f85a 100644 --- a/hdwallet/cryptocurrencies/bitcoinz.py +++ b/hdwallet/cryptocurrencies/bitcoinz.py @@ -55,9 +55,9 @@ class BitcoinZ(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitcore.py b/hdwallet/cryptocurrencies/bitcore.py index ac589fce..70c95a08 100644 --- a/hdwallet/cryptocurrencies/bitcore.py +++ b/hdwallet/cryptocurrencies/bitcore.py @@ -64,9 +64,9 @@ class Bitcore(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bitsend.py b/hdwallet/cryptocurrencies/bitsend.py index ffbddf22..3e475c8d 100644 --- a/hdwallet/cryptocurrencies/bitsend.py +++ b/hdwallet/cryptocurrencies/bitsend.py @@ -56,9 +56,9 @@ class BitSend(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/blackcoin.py b/hdwallet/cryptocurrencies/blackcoin.py index f543b8c5..66a68354 100644 --- a/hdwallet/cryptocurrencies/blackcoin.py +++ b/hdwallet/cryptocurrencies/blackcoin.py @@ -56,9 +56,9 @@ class Blackcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/blocknode.py b/hdwallet/cryptocurrencies/blocknode.py index dfdff4b5..73c326a6 100644 --- a/hdwallet/cryptocurrencies/blocknode.py +++ b/hdwallet/cryptocurrencies/blocknode.py @@ -71,9 +71,9 @@ class Blocknode(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/blockstamp.py b/hdwallet/cryptocurrencies/blockstamp.py index 1a044111..6bd6edcf 100644 --- a/hdwallet/cryptocurrencies/blockstamp.py +++ b/hdwallet/cryptocurrencies/blockstamp.py @@ -64,9 +64,9 @@ class BlockStamp(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/bolivarcoin.py b/hdwallet/cryptocurrencies/bolivarcoin.py index 8bd49dad..fc3142c4 100644 --- a/hdwallet/cryptocurrencies/bolivarcoin.py +++ b/hdwallet/cryptocurrencies/bolivarcoin.py @@ -54,9 +54,9 @@ class Bolivarcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/britcoin.py b/hdwallet/cryptocurrencies/britcoin.py index 40cc1ef0..e98b4b52 100644 --- a/hdwallet/cryptocurrencies/britcoin.py +++ b/hdwallet/cryptocurrencies/britcoin.py @@ -54,9 +54,9 @@ class BritCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/canadaecoin.py b/hdwallet/cryptocurrencies/canadaecoin.py index 269e3112..4303a3eb 100644 --- a/hdwallet/cryptocurrencies/canadaecoin.py +++ b/hdwallet/cryptocurrencies/canadaecoin.py @@ -54,9 +54,9 @@ class CanadaECoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/cannacoin.py b/hdwallet/cryptocurrencies/cannacoin.py index bd6edaf1..db1cc5c4 100644 --- a/hdwallet/cryptocurrencies/cannacoin.py +++ b/hdwallet/cryptocurrencies/cannacoin.py @@ -55,9 +55,9 @@ class Cannacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/celo.py b/hdwallet/cryptocurrencies/celo.py index 4897917d..240457e5 100644 --- a/hdwallet/cryptocurrencies/celo.py +++ b/hdwallet/cryptocurrencies/celo.py @@ -51,9 +51,9 @@ class Celo(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/chihuahua.py b/hdwallet/cryptocurrencies/chihuahua.py index 24514ff0..cc21c1fe 100644 --- a/hdwallet/cryptocurrencies/chihuahua.py +++ b/hdwallet/cryptocurrencies/chihuahua.py @@ -50,9 +50,9 @@ class Chihuahua(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/clams.py b/hdwallet/cryptocurrencies/clams.py index fb0c6104..08cdef4d 100644 --- a/hdwallet/cryptocurrencies/clams.py +++ b/hdwallet/cryptocurrencies/clams.py @@ -55,9 +55,9 @@ class Clams(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/clubcoin.py b/hdwallet/cryptocurrencies/clubcoin.py index edc4a303..91b6b351 100644 --- a/hdwallet/cryptocurrencies/clubcoin.py +++ b/hdwallet/cryptocurrencies/clubcoin.py @@ -54,9 +54,9 @@ class ClubCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/compcoin.py b/hdwallet/cryptocurrencies/compcoin.py index 9ca75672..67f74be4 100644 --- a/hdwallet/cryptocurrencies/compcoin.py +++ b/hdwallet/cryptocurrencies/compcoin.py @@ -53,9 +53,9 @@ class Compcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/cosmos.py b/hdwallet/cryptocurrencies/cosmos.py index 91b7fea9..8a7b7d19 100644 --- a/hdwallet/cryptocurrencies/cosmos.py +++ b/hdwallet/cryptocurrencies/cosmos.py @@ -51,9 +51,9 @@ class Cosmos(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/cpuchain.py b/hdwallet/cryptocurrencies/cpuchain.py index 09fa535c..5e1155fb 100644 --- a/hdwallet/cryptocurrencies/cpuchain.py +++ b/hdwallet/cryptocurrencies/cpuchain.py @@ -64,9 +64,9 @@ class CPUChain(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/cranepay.py b/hdwallet/cryptocurrencies/cranepay.py index b4be64d4..41988b34 100644 --- a/hdwallet/cryptocurrencies/cranepay.py +++ b/hdwallet/cryptocurrencies/cranepay.py @@ -63,9 +63,9 @@ class CranePay(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/crave.py b/hdwallet/cryptocurrencies/crave.py index 08b7a1c3..1532c5f8 100644 --- a/hdwallet/cryptocurrencies/crave.py +++ b/hdwallet/cryptocurrencies/crave.py @@ -55,9 +55,9 @@ class Crave(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/dash.py b/hdwallet/cryptocurrencies/dash.py index 3e56ccbc..ea3f139a 100644 --- a/hdwallet/cryptocurrencies/dash.py +++ b/hdwallet/cryptocurrencies/dash.py @@ -73,9 +73,9 @@ class Dash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/deeponion.py b/hdwallet/cryptocurrencies/deeponion.py index ed19902e..af1ecc1c 100644 --- a/hdwallet/cryptocurrencies/deeponion.py +++ b/hdwallet/cryptocurrencies/deeponion.py @@ -64,9 +64,9 @@ class DeepOnion(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/defcoin.py b/hdwallet/cryptocurrencies/defcoin.py index 9117b9b2..7ba309bf 100644 --- a/hdwallet/cryptocurrencies/defcoin.py +++ b/hdwallet/cryptocurrencies/defcoin.py @@ -54,9 +54,9 @@ class Defcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/denarius.py b/hdwallet/cryptocurrencies/denarius.py index d3f2d243..ab6e9e4f 100644 --- a/hdwallet/cryptocurrencies/denarius.py +++ b/hdwallet/cryptocurrencies/denarius.py @@ -55,9 +55,9 @@ class Denarius(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/diamond.py b/hdwallet/cryptocurrencies/diamond.py index 7899e3ea..34cb441a 100644 --- a/hdwallet/cryptocurrencies/diamond.py +++ b/hdwallet/cryptocurrencies/diamond.py @@ -55,9 +55,9 @@ class Diamond(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/digibyte.py b/hdwallet/cryptocurrencies/digibyte.py index 2544b601..8d17af8b 100644 --- a/hdwallet/cryptocurrencies/digibyte.py +++ b/hdwallet/cryptocurrencies/digibyte.py @@ -65,9 +65,9 @@ class DigiByte(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/digitalcoin.py b/hdwallet/cryptocurrencies/digitalcoin.py index ad8ccaa5..0f1909f3 100644 --- a/hdwallet/cryptocurrencies/digitalcoin.py +++ b/hdwallet/cryptocurrencies/digitalcoin.py @@ -56,9 +56,9 @@ class Digitalcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/divi.py b/hdwallet/cryptocurrencies/divi.py index b1eaf2c3..4a47419e 100644 --- a/hdwallet/cryptocurrencies/divi.py +++ b/hdwallet/cryptocurrencies/divi.py @@ -73,9 +73,9 @@ class Divi(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/dogecoin.py b/hdwallet/cryptocurrencies/dogecoin.py index 225df6f4..18914676 100644 --- a/hdwallet/cryptocurrencies/dogecoin.py +++ b/hdwallet/cryptocurrencies/dogecoin.py @@ -94,9 +94,9 @@ class Dogecoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/dydx.py b/hdwallet/cryptocurrencies/dydx.py index 99ce6721..e31dc609 100644 --- a/hdwallet/cryptocurrencies/dydx.py +++ b/hdwallet/cryptocurrencies/dydx.py @@ -50,9 +50,9 @@ class dYdX(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ecash.py b/hdwallet/cryptocurrencies/ecash.py index fdefb1a3..851f3a46 100644 --- a/hdwallet/cryptocurrencies/ecash.py +++ b/hdwallet/cryptocurrencies/ecash.py @@ -100,9 +100,9 @@ class eCash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ecoin.py b/hdwallet/cryptocurrencies/ecoin.py index eb994277..d4f58afd 100644 --- a/hdwallet/cryptocurrencies/ecoin.py +++ b/hdwallet/cryptocurrencies/ecoin.py @@ -54,9 +54,9 @@ class ECoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/edrcoin.py b/hdwallet/cryptocurrencies/edrcoin.py index 4e2ceac7..23292f81 100644 --- a/hdwallet/cryptocurrencies/edrcoin.py +++ b/hdwallet/cryptocurrencies/edrcoin.py @@ -55,9 +55,9 @@ class EDRCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/egulden.py b/hdwallet/cryptocurrencies/egulden.py index 461d1115..8c424c3c 100644 --- a/hdwallet/cryptocurrencies/egulden.py +++ b/hdwallet/cryptocurrencies/egulden.py @@ -54,9 +54,9 @@ class eGulden(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/einsteinium.py b/hdwallet/cryptocurrencies/einsteinium.py index e1381503..162ac5e0 100644 --- a/hdwallet/cryptocurrencies/einsteinium.py +++ b/hdwallet/cryptocurrencies/einsteinium.py @@ -55,9 +55,9 @@ class Einsteinium(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/elastos.py b/hdwallet/cryptocurrencies/elastos.py index 4c602776..06014f57 100644 --- a/hdwallet/cryptocurrencies/elastos.py +++ b/hdwallet/cryptocurrencies/elastos.py @@ -56,9 +56,9 @@ class Elastos(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/energi.py b/hdwallet/cryptocurrencies/energi.py index d1023448..b71ce5ee 100644 --- a/hdwallet/cryptocurrencies/energi.py +++ b/hdwallet/cryptocurrencies/energi.py @@ -56,9 +56,9 @@ class Energi(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/eos.py b/hdwallet/cryptocurrencies/eos.py index c3a90394..ba245a5e 100644 --- a/hdwallet/cryptocurrencies/eos.py +++ b/hdwallet/cryptocurrencies/eos.py @@ -50,9 +50,9 @@ class EOS(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ergo.py b/hdwallet/cryptocurrencies/ergo.py index dae087ac..43560a73 100644 --- a/hdwallet/cryptocurrencies/ergo.py +++ b/hdwallet/cryptocurrencies/ergo.py @@ -64,9 +64,9 @@ class Ergo(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ethereum.py b/hdwallet/cryptocurrencies/ethereum.py index c8a7104a..e947f467 100644 --- a/hdwallet/cryptocurrencies/ethereum.py +++ b/hdwallet/cryptocurrencies/ethereum.py @@ -51,9 +51,9 @@ class Ethereum(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/europecoin.py b/hdwallet/cryptocurrencies/europecoin.py index 4f2f93d6..1b620e45 100644 --- a/hdwallet/cryptocurrencies/europecoin.py +++ b/hdwallet/cryptocurrencies/europecoin.py @@ -55,9 +55,9 @@ class EuropeCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/evrmore.py b/hdwallet/cryptocurrencies/evrmore.py index 6acfb189..afa8f9e8 100644 --- a/hdwallet/cryptocurrencies/evrmore.py +++ b/hdwallet/cryptocurrencies/evrmore.py @@ -98,9 +98,9 @@ class Evrmore(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/exclusivecoin.py b/hdwallet/cryptocurrencies/exclusivecoin.py index efe2215f..ef07c064 100644 --- a/hdwallet/cryptocurrencies/exclusivecoin.py +++ b/hdwallet/cryptocurrencies/exclusivecoin.py @@ -54,9 +54,9 @@ class ExclusiveCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/fantom.py b/hdwallet/cryptocurrencies/fantom.py index b5e1636b..bb20b5b6 100644 --- a/hdwallet/cryptocurrencies/fantom.py +++ b/hdwallet/cryptocurrencies/fantom.py @@ -50,9 +50,9 @@ class Fantom(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/feathercoin.py b/hdwallet/cryptocurrencies/feathercoin.py index 0f6729a6..6d608cc9 100644 --- a/hdwallet/cryptocurrencies/feathercoin.py +++ b/hdwallet/cryptocurrencies/feathercoin.py @@ -55,9 +55,9 @@ class Feathercoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/fetchai.py b/hdwallet/cryptocurrencies/fetchai.py index c7fd68b3..280e273a 100644 --- a/hdwallet/cryptocurrencies/fetchai.py +++ b/hdwallet/cryptocurrencies/fetchai.py @@ -52,9 +52,9 @@ class FetchAI(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/filecoin.py b/hdwallet/cryptocurrencies/filecoin.py index 96f4c8c7..10097b1d 100644 --- a/hdwallet/cryptocurrencies/filecoin.py +++ b/hdwallet/cryptocurrencies/filecoin.py @@ -50,9 +50,9 @@ class Filecoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/firo.py b/hdwallet/cryptocurrencies/firo.py index cc3d1e84..034d03c6 100644 --- a/hdwallet/cryptocurrencies/firo.py +++ b/hdwallet/cryptocurrencies/firo.py @@ -54,9 +54,9 @@ class Firo(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/firstcoin.py b/hdwallet/cryptocurrencies/firstcoin.py index 74b986ab..9eaab7e2 100644 --- a/hdwallet/cryptocurrencies/firstcoin.py +++ b/hdwallet/cryptocurrencies/firstcoin.py @@ -54,9 +54,9 @@ class Firstcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/fix.py b/hdwallet/cryptocurrencies/fix.py index 425e20e4..a35a974d 100644 --- a/hdwallet/cryptocurrencies/fix.py +++ b/hdwallet/cryptocurrencies/fix.py @@ -71,9 +71,9 @@ class FIX(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/flashcoin.py b/hdwallet/cryptocurrencies/flashcoin.py index abf58e30..8fc6e59f 100644 --- a/hdwallet/cryptocurrencies/flashcoin.py +++ b/hdwallet/cryptocurrencies/flashcoin.py @@ -55,9 +55,9 @@ class Flashcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/flux.py b/hdwallet/cryptocurrencies/flux.py index 7ec3a575..66dff6eb 100644 --- a/hdwallet/cryptocurrencies/flux.py +++ b/hdwallet/cryptocurrencies/flux.py @@ -56,9 +56,9 @@ class Flux(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/foxdcoin.py b/hdwallet/cryptocurrencies/foxdcoin.py index 5cc9d128..b94e27b3 100644 --- a/hdwallet/cryptocurrencies/foxdcoin.py +++ b/hdwallet/cryptocurrencies/foxdcoin.py @@ -97,9 +97,9 @@ class Foxdcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/fujicoin.py b/hdwallet/cryptocurrencies/fujicoin.py index fd1a19ce..a1c85a21 100644 --- a/hdwallet/cryptocurrencies/fujicoin.py +++ b/hdwallet/cryptocurrencies/fujicoin.py @@ -64,9 +64,9 @@ class FujiCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/gamecredits.py b/hdwallet/cryptocurrencies/gamecredits.py index 21440ca1..3e00b8b3 100644 --- a/hdwallet/cryptocurrencies/gamecredits.py +++ b/hdwallet/cryptocurrencies/gamecredits.py @@ -54,9 +54,9 @@ class GameCredits(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/gcrcoin.py b/hdwallet/cryptocurrencies/gcrcoin.py index 7549df5c..b0749ff3 100644 --- a/hdwallet/cryptocurrencies/gcrcoin.py +++ b/hdwallet/cryptocurrencies/gcrcoin.py @@ -53,9 +53,9 @@ class GCRCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/gobyte.py b/hdwallet/cryptocurrencies/gobyte.py index beb77d1a..50da93f3 100644 --- a/hdwallet/cryptocurrencies/gobyte.py +++ b/hdwallet/cryptocurrencies/gobyte.py @@ -55,9 +55,9 @@ class GoByte(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/gridcoin.py b/hdwallet/cryptocurrencies/gridcoin.py index 7f44d608..9def6187 100644 --- a/hdwallet/cryptocurrencies/gridcoin.py +++ b/hdwallet/cryptocurrencies/gridcoin.py @@ -55,9 +55,9 @@ class Gridcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/groestlcoin.py b/hdwallet/cryptocurrencies/groestlcoin.py index 12175f06..47d4c0cb 100644 --- a/hdwallet/cryptocurrencies/groestlcoin.py +++ b/hdwallet/cryptocurrencies/groestlcoin.py @@ -90,9 +90,9 @@ class GroestlCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/gulden.py b/hdwallet/cryptocurrencies/gulden.py index 608c4836..2f105780 100644 --- a/hdwallet/cryptocurrencies/gulden.py +++ b/hdwallet/cryptocurrencies/gulden.py @@ -54,9 +54,9 @@ class Gulden(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/harmony.py b/hdwallet/cryptocurrencies/harmony.py index 983a20d4..8947903a 100644 --- a/hdwallet/cryptocurrencies/harmony.py +++ b/hdwallet/cryptocurrencies/harmony.py @@ -52,9 +52,9 @@ class Harmony(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/helleniccoin.py b/hdwallet/cryptocurrencies/helleniccoin.py index 1680d963..f4459c3d 100644 --- a/hdwallet/cryptocurrencies/helleniccoin.py +++ b/hdwallet/cryptocurrencies/helleniccoin.py @@ -56,9 +56,9 @@ class Helleniccoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/hempcoin.py b/hdwallet/cryptocurrencies/hempcoin.py index bd03eb7b..f406bf6c 100644 --- a/hdwallet/cryptocurrencies/hempcoin.py +++ b/hdwallet/cryptocurrencies/hempcoin.py @@ -55,9 +55,9 @@ class Hempcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/horizen.py b/hdwallet/cryptocurrencies/horizen.py index 4b0deaee..5e3cb17e 100644 --- a/hdwallet/cryptocurrencies/horizen.py +++ b/hdwallet/cryptocurrencies/horizen.py @@ -56,9 +56,9 @@ class Horizen(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/huobitoken.py b/hdwallet/cryptocurrencies/huobitoken.py index e3498606..76bf17d9 100644 --- a/hdwallet/cryptocurrencies/huobitoken.py +++ b/hdwallet/cryptocurrencies/huobitoken.py @@ -49,9 +49,9 @@ class HuobiToken(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/hush.py b/hdwallet/cryptocurrencies/hush.py index 271c0f1f..aec36a2f 100644 --- a/hdwallet/cryptocurrencies/hush.py +++ b/hdwallet/cryptocurrencies/hush.py @@ -56,9 +56,9 @@ class Hush(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/icon.py b/hdwallet/cryptocurrencies/icon.py index e8614038..9c8a0e2d 100644 --- a/hdwallet/cryptocurrencies/icon.py +++ b/hdwallet/cryptocurrencies/icon.py @@ -51,9 +51,9 @@ class Icon(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/injective.py b/hdwallet/cryptocurrencies/injective.py index 192f9b89..638d7bbd 100644 --- a/hdwallet/cryptocurrencies/injective.py +++ b/hdwallet/cryptocurrencies/injective.py @@ -51,9 +51,9 @@ class Injective(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/insanecoin.py b/hdwallet/cryptocurrencies/insanecoin.py index 1295efa9..3acbc8e9 100644 --- a/hdwallet/cryptocurrencies/insanecoin.py +++ b/hdwallet/cryptocurrencies/insanecoin.py @@ -55,9 +55,9 @@ class InsaneCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/internetofpeople.py b/hdwallet/cryptocurrencies/internetofpeople.py index b7a5dac9..95de0e39 100644 --- a/hdwallet/cryptocurrencies/internetofpeople.py +++ b/hdwallet/cryptocurrencies/internetofpeople.py @@ -55,9 +55,9 @@ class InternetOfPeople(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/irisnet.py b/hdwallet/cryptocurrencies/irisnet.py index 64926990..afa9492d 100644 --- a/hdwallet/cryptocurrencies/irisnet.py +++ b/hdwallet/cryptocurrencies/irisnet.py @@ -51,9 +51,9 @@ class IRISnet(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ixcoin.py b/hdwallet/cryptocurrencies/ixcoin.py index 021fa006..d088e81c 100644 --- a/hdwallet/cryptocurrencies/ixcoin.py +++ b/hdwallet/cryptocurrencies/ixcoin.py @@ -55,9 +55,9 @@ class IXCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/jumbucks.py b/hdwallet/cryptocurrencies/jumbucks.py index 932bb2c2..c839d8c9 100644 --- a/hdwallet/cryptocurrencies/jumbucks.py +++ b/hdwallet/cryptocurrencies/jumbucks.py @@ -53,9 +53,9 @@ class Jumbucks(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/kava.py b/hdwallet/cryptocurrencies/kava.py index 2778a1b0..8acd2013 100644 --- a/hdwallet/cryptocurrencies/kava.py +++ b/hdwallet/cryptocurrencies/kava.py @@ -52,9 +52,9 @@ class Kava(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/kobocoin.py b/hdwallet/cryptocurrencies/kobocoin.py index dc8c1fc1..f9449cd1 100644 --- a/hdwallet/cryptocurrencies/kobocoin.py +++ b/hdwallet/cryptocurrencies/kobocoin.py @@ -55,9 +55,9 @@ class Kobocoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/komodo.py b/hdwallet/cryptocurrencies/komodo.py index c2d8ba52..de510e82 100644 --- a/hdwallet/cryptocurrencies/komodo.py +++ b/hdwallet/cryptocurrencies/komodo.py @@ -55,9 +55,9 @@ class Komodo(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/landcoin.py b/hdwallet/cryptocurrencies/landcoin.py index a56b6264..45788982 100644 --- a/hdwallet/cryptocurrencies/landcoin.py +++ b/hdwallet/cryptocurrencies/landcoin.py @@ -53,9 +53,9 @@ class Landcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/lbrycredits.py b/hdwallet/cryptocurrencies/lbrycredits.py index ee162ad7..afa8ee34 100644 --- a/hdwallet/cryptocurrencies/lbrycredits.py +++ b/hdwallet/cryptocurrencies/lbrycredits.py @@ -56,9 +56,9 @@ class LBRYCredits(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/linx.py b/hdwallet/cryptocurrencies/linx.py index e5a1986f..6894a336 100644 --- a/hdwallet/cryptocurrencies/linx.py +++ b/hdwallet/cryptocurrencies/linx.py @@ -55,9 +55,9 @@ class Linx(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/litecoin.py b/hdwallet/cryptocurrencies/litecoin.py index 36c758ff..fc6fef72 100644 --- a/hdwallet/cryptocurrencies/litecoin.py +++ b/hdwallet/cryptocurrencies/litecoin.py @@ -97,9 +97,9 @@ class Litecoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44", "BIP84" }) diff --git a/hdwallet/cryptocurrencies/litecoincash.py b/hdwallet/cryptocurrencies/litecoincash.py index 65074bb6..10568658 100644 --- a/hdwallet/cryptocurrencies/litecoincash.py +++ b/hdwallet/cryptocurrencies/litecoincash.py @@ -55,9 +55,9 @@ class LitecoinCash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/litecoinz.py b/hdwallet/cryptocurrencies/litecoinz.py index 140b83bc..01d0bfc2 100644 --- a/hdwallet/cryptocurrencies/litecoinz.py +++ b/hdwallet/cryptocurrencies/litecoinz.py @@ -55,9 +55,9 @@ class LitecoinZ(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/lkrcoin.py b/hdwallet/cryptocurrencies/lkrcoin.py index 1531a750..949b5779 100644 --- a/hdwallet/cryptocurrencies/lkrcoin.py +++ b/hdwallet/cryptocurrencies/lkrcoin.py @@ -55,9 +55,9 @@ class Lkrcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/lynx.py b/hdwallet/cryptocurrencies/lynx.py index 5a0e20f3..b887aeab 100644 --- a/hdwallet/cryptocurrencies/lynx.py +++ b/hdwallet/cryptocurrencies/lynx.py @@ -54,9 +54,9 @@ class Lynx(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/mazacoin.py b/hdwallet/cryptocurrencies/mazacoin.py index 71df9265..ba698f31 100644 --- a/hdwallet/cryptocurrencies/mazacoin.py +++ b/hdwallet/cryptocurrencies/mazacoin.py @@ -54,9 +54,9 @@ class Mazacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/megacoin.py b/hdwallet/cryptocurrencies/megacoin.py index 03e63cae..50bb5d0d 100644 --- a/hdwallet/cryptocurrencies/megacoin.py +++ b/hdwallet/cryptocurrencies/megacoin.py @@ -55,9 +55,9 @@ class Megacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/metis.py b/hdwallet/cryptocurrencies/metis.py index fd5ce089..d4f9575d 100644 --- a/hdwallet/cryptocurrencies/metis.py +++ b/hdwallet/cryptocurrencies/metis.py @@ -50,9 +50,9 @@ class Metis(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/minexcoin.py b/hdwallet/cryptocurrencies/minexcoin.py index 82e7b3be..83c5d1a5 100644 --- a/hdwallet/cryptocurrencies/minexcoin.py +++ b/hdwallet/cryptocurrencies/minexcoin.py @@ -55,9 +55,9 @@ class Minexcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/monacoin.py b/hdwallet/cryptocurrencies/monacoin.py index e3cc8056..476cdb72 100644 --- a/hdwallet/cryptocurrencies/monacoin.py +++ b/hdwallet/cryptocurrencies/monacoin.py @@ -64,9 +64,9 @@ class Monacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/monero.py b/hdwallet/cryptocurrencies/monero.py index bc69afee..90b01c6f 100644 --- a/hdwallet/cryptocurrencies/monero.py +++ b/hdwallet/cryptocurrencies/monero.py @@ -63,7 +63,7 @@ class Monero(ICryptocurrency): {"MONERO": "Monero"}, "BIP39" )) SEEDS = Seeds(( - {"MONERO": "Monero"}, "BIP39" + {"MONERO": "Monero"}, "BIP39", "SLIP39" )) HDS = HDs({ "MONERO": "Monero" diff --git a/hdwallet/cryptocurrencies/monk.py b/hdwallet/cryptocurrencies/monk.py index 0ba504fb..e89150ca 100644 --- a/hdwallet/cryptocurrencies/monk.py +++ b/hdwallet/cryptocurrencies/monk.py @@ -64,9 +64,9 @@ class Monk(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/multiversx.py b/hdwallet/cryptocurrencies/multiversx.py index 4e692399..a3a6dc79 100644 --- a/hdwallet/cryptocurrencies/multiversx.py +++ b/hdwallet/cryptocurrencies/multiversx.py @@ -51,9 +51,9 @@ class MultiversX(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/myriadcoin.py b/hdwallet/cryptocurrencies/myriadcoin.py index ee715796..e993e8c3 100644 --- a/hdwallet/cryptocurrencies/myriadcoin.py +++ b/hdwallet/cryptocurrencies/myriadcoin.py @@ -54,9 +54,9 @@ class Myriadcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/namecoin.py b/hdwallet/cryptocurrencies/namecoin.py index e8c2e1d2..ed07b21b 100644 --- a/hdwallet/cryptocurrencies/namecoin.py +++ b/hdwallet/cryptocurrencies/namecoin.py @@ -55,9 +55,9 @@ class Namecoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/nano.py b/hdwallet/cryptocurrencies/nano.py index 17a3c711..130f2d4c 100644 --- a/hdwallet/cryptocurrencies/nano.py +++ b/hdwallet/cryptocurrencies/nano.py @@ -49,9 +49,9 @@ class Nano(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/navcoin.py b/hdwallet/cryptocurrencies/navcoin.py index 671234e3..b3e8c0fa 100644 --- a/hdwallet/cryptocurrencies/navcoin.py +++ b/hdwallet/cryptocurrencies/navcoin.py @@ -55,9 +55,9 @@ class Navcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/near.py b/hdwallet/cryptocurrencies/near.py index 16694377..d8b4af61 100644 --- a/hdwallet/cryptocurrencies/near.py +++ b/hdwallet/cryptocurrencies/near.py @@ -49,9 +49,9 @@ class Near(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/neblio.py b/hdwallet/cryptocurrencies/neblio.py index bc57fc1c..3d9ed8cb 100644 --- a/hdwallet/cryptocurrencies/neblio.py +++ b/hdwallet/cryptocurrencies/neblio.py @@ -55,9 +55,9 @@ class Neblio(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/neo.py b/hdwallet/cryptocurrencies/neo.py index 77e538ca..05b5c368 100644 --- a/hdwallet/cryptocurrencies/neo.py +++ b/hdwallet/cryptocurrencies/neo.py @@ -49,9 +49,9 @@ class Neo(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/neoscoin.py b/hdwallet/cryptocurrencies/neoscoin.py index bd5c330c..e3359281 100644 --- a/hdwallet/cryptocurrencies/neoscoin.py +++ b/hdwallet/cryptocurrencies/neoscoin.py @@ -54,9 +54,9 @@ class Neoscoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/neurocoin.py b/hdwallet/cryptocurrencies/neurocoin.py index deedb56a..85b1a5c0 100644 --- a/hdwallet/cryptocurrencies/neurocoin.py +++ b/hdwallet/cryptocurrencies/neurocoin.py @@ -54,9 +54,9 @@ class Neurocoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/neutron.py b/hdwallet/cryptocurrencies/neutron.py index 2937d9c5..7689b4f9 100644 --- a/hdwallet/cryptocurrencies/neutron.py +++ b/hdwallet/cryptocurrencies/neutron.py @@ -51,9 +51,9 @@ class Neutron(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/newyorkcoin.py b/hdwallet/cryptocurrencies/newyorkcoin.py index 381d3b34..5e8aa36a 100644 --- a/hdwallet/cryptocurrencies/newyorkcoin.py +++ b/hdwallet/cryptocurrencies/newyorkcoin.py @@ -55,9 +55,9 @@ class NewYorkCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ninechronicles.py b/hdwallet/cryptocurrencies/ninechronicles.py index 1b5670d3..6aad468e 100644 --- a/hdwallet/cryptocurrencies/ninechronicles.py +++ b/hdwallet/cryptocurrencies/ninechronicles.py @@ -50,9 +50,9 @@ class NineChronicles(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/nix.py b/hdwallet/cryptocurrencies/nix.py index 1fca1d7a..4b0bdcfa 100644 --- a/hdwallet/cryptocurrencies/nix.py +++ b/hdwallet/cryptocurrencies/nix.py @@ -65,9 +65,9 @@ class NIX(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/novacoin.py b/hdwallet/cryptocurrencies/novacoin.py index a44910d5..ea35a3dc 100644 --- a/hdwallet/cryptocurrencies/novacoin.py +++ b/hdwallet/cryptocurrencies/novacoin.py @@ -56,9 +56,9 @@ class Novacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/nubits.py b/hdwallet/cryptocurrencies/nubits.py index 0848f914..768c7258 100644 --- a/hdwallet/cryptocurrencies/nubits.py +++ b/hdwallet/cryptocurrencies/nubits.py @@ -55,9 +55,9 @@ class NuBits(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/nushares.py b/hdwallet/cryptocurrencies/nushares.py index b9602f3c..3167cc5e 100644 --- a/hdwallet/cryptocurrencies/nushares.py +++ b/hdwallet/cryptocurrencies/nushares.py @@ -55,9 +55,9 @@ class NuShares(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/okcash.py b/hdwallet/cryptocurrencies/okcash.py index 75956b37..de2cea66 100644 --- a/hdwallet/cryptocurrencies/okcash.py +++ b/hdwallet/cryptocurrencies/okcash.py @@ -55,9 +55,9 @@ class OKCash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/oktchain.py b/hdwallet/cryptocurrencies/oktchain.py index e3d055db..6171d44b 100644 --- a/hdwallet/cryptocurrencies/oktchain.py +++ b/hdwallet/cryptocurrencies/oktchain.py @@ -51,9 +51,9 @@ class OKTChain(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/omni.py b/hdwallet/cryptocurrencies/omni.py index ffc3e809..096a5856 100644 --- a/hdwallet/cryptocurrencies/omni.py +++ b/hdwallet/cryptocurrencies/omni.py @@ -72,9 +72,9 @@ class Omni(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/onix.py b/hdwallet/cryptocurrencies/onix.py index d0bb527a..71d9d82d 100644 --- a/hdwallet/cryptocurrencies/onix.py +++ b/hdwallet/cryptocurrencies/onix.py @@ -54,9 +54,9 @@ class Onix(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ontology.py b/hdwallet/cryptocurrencies/ontology.py index 1aa22085..2e6635c3 100644 --- a/hdwallet/cryptocurrencies/ontology.py +++ b/hdwallet/cryptocurrencies/ontology.py @@ -49,9 +49,9 @@ class Ontology(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/optimism.py b/hdwallet/cryptocurrencies/optimism.py index 95ca632f..beba9ff9 100644 --- a/hdwallet/cryptocurrencies/optimism.py +++ b/hdwallet/cryptocurrencies/optimism.py @@ -49,9 +49,9 @@ class Optimism(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/osmosis.py b/hdwallet/cryptocurrencies/osmosis.py index e519a897..2fa80d09 100644 --- a/hdwallet/cryptocurrencies/osmosis.py +++ b/hdwallet/cryptocurrencies/osmosis.py @@ -50,9 +50,9 @@ class Osmosis(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/particl.py b/hdwallet/cryptocurrencies/particl.py index 74be9417..158dcfd8 100644 --- a/hdwallet/cryptocurrencies/particl.py +++ b/hdwallet/cryptocurrencies/particl.py @@ -61,9 +61,9 @@ class Particl(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/peercoin.py b/hdwallet/cryptocurrencies/peercoin.py index 884fa191..db5e7c0a 100644 --- a/hdwallet/cryptocurrencies/peercoin.py +++ b/hdwallet/cryptocurrencies/peercoin.py @@ -55,9 +55,9 @@ class Peercoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/pesobit.py b/hdwallet/cryptocurrencies/pesobit.py index 26c8a36f..c7c939d2 100644 --- a/hdwallet/cryptocurrencies/pesobit.py +++ b/hdwallet/cryptocurrencies/pesobit.py @@ -54,9 +54,9 @@ class Pesobit(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/phore.py b/hdwallet/cryptocurrencies/phore.py index 50026096..863b93b5 100644 --- a/hdwallet/cryptocurrencies/phore.py +++ b/hdwallet/cryptocurrencies/phore.py @@ -55,9 +55,9 @@ class Phore(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/pinetwork.py b/hdwallet/cryptocurrencies/pinetwork.py index cee939fb..e5b12605 100644 --- a/hdwallet/cryptocurrencies/pinetwork.py +++ b/hdwallet/cryptocurrencies/pinetwork.py @@ -49,9 +49,9 @@ class PiNetwork(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/pinkcoin.py b/hdwallet/cryptocurrencies/pinkcoin.py index 49609969..4539df99 100644 --- a/hdwallet/cryptocurrencies/pinkcoin.py +++ b/hdwallet/cryptocurrencies/pinkcoin.py @@ -55,9 +55,9 @@ class Pinkcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/pivx.py b/hdwallet/cryptocurrencies/pivx.py index b361b387..1b6bd290 100644 --- a/hdwallet/cryptocurrencies/pivx.py +++ b/hdwallet/cryptocurrencies/pivx.py @@ -72,9 +72,9 @@ class Pivx(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/polygon.py b/hdwallet/cryptocurrencies/polygon.py index e4e408f4..00729dc5 100644 --- a/hdwallet/cryptocurrencies/polygon.py +++ b/hdwallet/cryptocurrencies/polygon.py @@ -50,9 +50,9 @@ class Polygon(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/poswcoin.py b/hdwallet/cryptocurrencies/poswcoin.py index 06fbd817..10ab60d8 100644 --- a/hdwallet/cryptocurrencies/poswcoin.py +++ b/hdwallet/cryptocurrencies/poswcoin.py @@ -53,9 +53,9 @@ class PoSWCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/potcoin.py b/hdwallet/cryptocurrencies/potcoin.py index 47bfb912..3471fb38 100644 --- a/hdwallet/cryptocurrencies/potcoin.py +++ b/hdwallet/cryptocurrencies/potcoin.py @@ -55,9 +55,9 @@ class Potcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/projectcoin.py b/hdwallet/cryptocurrencies/projectcoin.py index 55713df1..12efd2bb 100644 --- a/hdwallet/cryptocurrencies/projectcoin.py +++ b/hdwallet/cryptocurrencies/projectcoin.py @@ -54,9 +54,9 @@ class ProjectCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/putincoin.py b/hdwallet/cryptocurrencies/putincoin.py index b5c8d819..504dff92 100644 --- a/hdwallet/cryptocurrencies/putincoin.py +++ b/hdwallet/cryptocurrencies/putincoin.py @@ -55,9 +55,9 @@ class Putincoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/qtum.py b/hdwallet/cryptocurrencies/qtum.py index 3504fc35..0ee1d270 100644 --- a/hdwallet/cryptocurrencies/qtum.py +++ b/hdwallet/cryptocurrencies/qtum.py @@ -101,9 +101,9 @@ class Qtum(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39", }) - SEEDS = Seeds({ - "BIP39", - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44", "BIP49", "BIP84", "BIP86", "BIP141" }) diff --git a/hdwallet/cryptocurrencies/rapids.py b/hdwallet/cryptocurrencies/rapids.py index 2d8c4cd6..6da56f6e 100644 --- a/hdwallet/cryptocurrencies/rapids.py +++ b/hdwallet/cryptocurrencies/rapids.py @@ -55,9 +55,9 @@ class Rapids(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ravencoin.py b/hdwallet/cryptocurrencies/ravencoin.py index 3be06c31..96b3f525 100644 --- a/hdwallet/cryptocurrencies/ravencoin.py +++ b/hdwallet/cryptocurrencies/ravencoin.py @@ -99,9 +99,9 @@ class Ravencoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs(( "BIP32", "BIP44" )) diff --git a/hdwallet/cryptocurrencies/reddcoin.py b/hdwallet/cryptocurrencies/reddcoin.py index a9f87555..050459f9 100644 --- a/hdwallet/cryptocurrencies/reddcoin.py +++ b/hdwallet/cryptocurrencies/reddcoin.py @@ -56,9 +56,9 @@ class Reddcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ripple.py b/hdwallet/cryptocurrencies/ripple.py index 9b104c65..52771bd6 100644 --- a/hdwallet/cryptocurrencies/ripple.py +++ b/hdwallet/cryptocurrencies/ripple.py @@ -55,9 +55,9 @@ class Ripple(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ritocoin.py b/hdwallet/cryptocurrencies/ritocoin.py index ef2b3160..87cfbed2 100644 --- a/hdwallet/cryptocurrencies/ritocoin.py +++ b/hdwallet/cryptocurrencies/ritocoin.py @@ -55,9 +55,9 @@ class Ritocoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/rsk.py b/hdwallet/cryptocurrencies/rsk.py index 3eabdf4b..e272d736 100644 --- a/hdwallet/cryptocurrencies/rsk.py +++ b/hdwallet/cryptocurrencies/rsk.py @@ -72,9 +72,9 @@ class RSK(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/rubycoin.py b/hdwallet/cryptocurrencies/rubycoin.py index b6053b78..f866f5e4 100644 --- a/hdwallet/cryptocurrencies/rubycoin.py +++ b/hdwallet/cryptocurrencies/rubycoin.py @@ -54,9 +54,9 @@ class Rubycoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/safecoin.py b/hdwallet/cryptocurrencies/safecoin.py index d237b78b..fd01ad94 100644 --- a/hdwallet/cryptocurrencies/safecoin.py +++ b/hdwallet/cryptocurrencies/safecoin.py @@ -55,9 +55,9 @@ class Safecoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/saluscoin.py b/hdwallet/cryptocurrencies/saluscoin.py index c2329d29..955be970 100644 --- a/hdwallet/cryptocurrencies/saluscoin.py +++ b/hdwallet/cryptocurrencies/saluscoin.py @@ -55,9 +55,9 @@ class Saluscoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/scribe.py b/hdwallet/cryptocurrencies/scribe.py index 2b71a60e..aa5796c1 100644 --- a/hdwallet/cryptocurrencies/scribe.py +++ b/hdwallet/cryptocurrencies/scribe.py @@ -54,9 +54,9 @@ class Scribe(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/secret.py b/hdwallet/cryptocurrencies/secret.py index 96576f14..ad9bfd66 100644 --- a/hdwallet/cryptocurrencies/secret.py +++ b/hdwallet/cryptocurrencies/secret.py @@ -51,9 +51,9 @@ class Secret(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/shadowcash.py b/hdwallet/cryptocurrencies/shadowcash.py index 3c7ebca6..6f3fb2bd 100644 --- a/hdwallet/cryptocurrencies/shadowcash.py +++ b/hdwallet/cryptocurrencies/shadowcash.py @@ -72,9 +72,9 @@ class ShadowCash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/shentu.py b/hdwallet/cryptocurrencies/shentu.py index 11058588..5b67e178 100644 --- a/hdwallet/cryptocurrencies/shentu.py +++ b/hdwallet/cryptocurrencies/shentu.py @@ -51,9 +51,9 @@ class Shentu(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/slimcoin.py b/hdwallet/cryptocurrencies/slimcoin.py index 3ce5ae8a..ef45d336 100644 --- a/hdwallet/cryptocurrencies/slimcoin.py +++ b/hdwallet/cryptocurrencies/slimcoin.py @@ -72,9 +72,9 @@ class Slimcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/smileycoin.py b/hdwallet/cryptocurrencies/smileycoin.py index 015ab02f..535f005a 100644 --- a/hdwallet/cryptocurrencies/smileycoin.py +++ b/hdwallet/cryptocurrencies/smileycoin.py @@ -55,9 +55,9 @@ class Smileycoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/solana.py b/hdwallet/cryptocurrencies/solana.py index a4953e4f..f07dda99 100644 --- a/hdwallet/cryptocurrencies/solana.py +++ b/hdwallet/cryptocurrencies/solana.py @@ -49,9 +49,9 @@ class Solana(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/solarcoin.py b/hdwallet/cryptocurrencies/solarcoin.py index 8b290c52..e97b79ac 100644 --- a/hdwallet/cryptocurrencies/solarcoin.py +++ b/hdwallet/cryptocurrencies/solarcoin.py @@ -56,9 +56,9 @@ class Solarcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/stafi.py b/hdwallet/cryptocurrencies/stafi.py index b9d057db..9d4d717d 100644 --- a/hdwallet/cryptocurrencies/stafi.py +++ b/hdwallet/cryptocurrencies/stafi.py @@ -51,9 +51,9 @@ class Stafi(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/stash.py b/hdwallet/cryptocurrencies/stash.py index b51b8200..1cb5f7ac 100644 --- a/hdwallet/cryptocurrencies/stash.py +++ b/hdwallet/cryptocurrencies/stash.py @@ -73,9 +73,9 @@ class Stash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/stellar.py b/hdwallet/cryptocurrencies/stellar.py index 107b5bce..0da69108 100644 --- a/hdwallet/cryptocurrencies/stellar.py +++ b/hdwallet/cryptocurrencies/stellar.py @@ -49,9 +49,9 @@ class Stellar(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/stratis.py b/hdwallet/cryptocurrencies/stratis.py index f361fdfb..d20d4e91 100644 --- a/hdwallet/cryptocurrencies/stratis.py +++ b/hdwallet/cryptocurrencies/stratis.py @@ -73,9 +73,9 @@ class Stratis(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/sugarchain.py b/hdwallet/cryptocurrencies/sugarchain.py index eec97d49..e409f973 100644 --- a/hdwallet/cryptocurrencies/sugarchain.py +++ b/hdwallet/cryptocurrencies/sugarchain.py @@ -90,9 +90,9 @@ class Sugarchain(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/sui.py b/hdwallet/cryptocurrencies/sui.py index caa0e687..53157286 100644 --- a/hdwallet/cryptocurrencies/sui.py +++ b/hdwallet/cryptocurrencies/sui.py @@ -49,9 +49,9 @@ class Sui(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/syscoin.py b/hdwallet/cryptocurrencies/syscoin.py index 58b2f2f6..1293d4b4 100644 --- a/hdwallet/cryptocurrencies/syscoin.py +++ b/hdwallet/cryptocurrencies/syscoin.py @@ -64,9 +64,9 @@ class Syscoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/terra.py b/hdwallet/cryptocurrencies/terra.py index db1ad078..20020907 100644 --- a/hdwallet/cryptocurrencies/terra.py +++ b/hdwallet/cryptocurrencies/terra.py @@ -51,9 +51,9 @@ class Terra(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/tezos.py b/hdwallet/cryptocurrencies/tezos.py index 3ec1b8ad..aa533c19 100644 --- a/hdwallet/cryptocurrencies/tezos.py +++ b/hdwallet/cryptocurrencies/tezos.py @@ -49,9 +49,9 @@ class Tezos(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/theta.py b/hdwallet/cryptocurrencies/theta.py index 0f4ac698..0db464df 100644 --- a/hdwallet/cryptocurrencies/theta.py +++ b/hdwallet/cryptocurrencies/theta.py @@ -50,9 +50,9 @@ class Theta(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/thoughtai.py b/hdwallet/cryptocurrencies/thoughtai.py index 3a0ecefe..2f886140 100644 --- a/hdwallet/cryptocurrencies/thoughtai.py +++ b/hdwallet/cryptocurrencies/thoughtai.py @@ -55,9 +55,9 @@ class ThoughtAI(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/toacoin.py b/hdwallet/cryptocurrencies/toacoin.py index 4c18fd73..40189e69 100644 --- a/hdwallet/cryptocurrencies/toacoin.py +++ b/hdwallet/cryptocurrencies/toacoin.py @@ -55,9 +55,9 @@ class TOACoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/tron.py b/hdwallet/cryptocurrencies/tron.py index b4f484f5..567ea186 100644 --- a/hdwallet/cryptocurrencies/tron.py +++ b/hdwallet/cryptocurrencies/tron.py @@ -56,9 +56,9 @@ class Tron(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/twins.py b/hdwallet/cryptocurrencies/twins.py index f8921958..1d6a2a23 100644 --- a/hdwallet/cryptocurrencies/twins.py +++ b/hdwallet/cryptocurrencies/twins.py @@ -72,9 +72,9 @@ class TWINS(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ultimatesecurecash.py b/hdwallet/cryptocurrencies/ultimatesecurecash.py index 743d38f6..675ab8a9 100644 --- a/hdwallet/cryptocurrencies/ultimatesecurecash.py +++ b/hdwallet/cryptocurrencies/ultimatesecurecash.py @@ -55,9 +55,9 @@ class UltimateSecureCash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/unobtanium.py b/hdwallet/cryptocurrencies/unobtanium.py index ccfef275..66ac7064 100644 --- a/hdwallet/cryptocurrencies/unobtanium.py +++ b/hdwallet/cryptocurrencies/unobtanium.py @@ -54,9 +54,9 @@ class Unobtanium(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/vcash.py b/hdwallet/cryptocurrencies/vcash.py index ecb43af4..a9c1305e 100644 --- a/hdwallet/cryptocurrencies/vcash.py +++ b/hdwallet/cryptocurrencies/vcash.py @@ -54,9 +54,9 @@ class Vcash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/vechain.py b/hdwallet/cryptocurrencies/vechain.py index 5bfd6cc3..811368b8 100644 --- a/hdwallet/cryptocurrencies/vechain.py +++ b/hdwallet/cryptocurrencies/vechain.py @@ -51,9 +51,9 @@ class VeChain(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/verge.py b/hdwallet/cryptocurrencies/verge.py index fc8df9a7..e43bc3dd 100644 --- a/hdwallet/cryptocurrencies/verge.py +++ b/hdwallet/cryptocurrencies/verge.py @@ -55,9 +55,9 @@ class Verge(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/vertcoin.py b/hdwallet/cryptocurrencies/vertcoin.py index b4558ca4..7daa8bb4 100644 --- a/hdwallet/cryptocurrencies/vertcoin.py +++ b/hdwallet/cryptocurrencies/vertcoin.py @@ -64,9 +64,9 @@ class Vertcoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/viacoin.py b/hdwallet/cryptocurrencies/viacoin.py index f0fefe4b..1323ceb3 100644 --- a/hdwallet/cryptocurrencies/viacoin.py +++ b/hdwallet/cryptocurrencies/viacoin.py @@ -90,9 +90,9 @@ class Viacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/vivo.py b/hdwallet/cryptocurrencies/vivo.py index a1a57985..7ac5849f 100644 --- a/hdwallet/cryptocurrencies/vivo.py +++ b/hdwallet/cryptocurrencies/vivo.py @@ -55,9 +55,9 @@ class Vivo(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/voxels.py b/hdwallet/cryptocurrencies/voxels.py index 5a8a7ed8..898ae4f5 100644 --- a/hdwallet/cryptocurrencies/voxels.py +++ b/hdwallet/cryptocurrencies/voxels.py @@ -54,9 +54,9 @@ class Voxels(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/vpncoin.py b/hdwallet/cryptocurrencies/vpncoin.py index 1c466015..5bdbb4e3 100644 --- a/hdwallet/cryptocurrencies/vpncoin.py +++ b/hdwallet/cryptocurrencies/vpncoin.py @@ -54,9 +54,9 @@ class VPNCoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/wagerr.py b/hdwallet/cryptocurrencies/wagerr.py index e5e17dea..e7d15006 100644 --- a/hdwallet/cryptocurrencies/wagerr.py +++ b/hdwallet/cryptocurrencies/wagerr.py @@ -54,9 +54,9 @@ class Wagerr(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/whitecoin.py b/hdwallet/cryptocurrencies/whitecoin.py index e9d1ec36..f8dd2ee4 100644 --- a/hdwallet/cryptocurrencies/whitecoin.py +++ b/hdwallet/cryptocurrencies/whitecoin.py @@ -56,9 +56,9 @@ class Whitecoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/wincoin.py b/hdwallet/cryptocurrencies/wincoin.py index b460ddf2..d1c2c356 100644 --- a/hdwallet/cryptocurrencies/wincoin.py +++ b/hdwallet/cryptocurrencies/wincoin.py @@ -55,9 +55,9 @@ class Wincoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/xinfin.py b/hdwallet/cryptocurrencies/xinfin.py index 37e19450..a149ae0b 100644 --- a/hdwallet/cryptocurrencies/xinfin.py +++ b/hdwallet/cryptocurrencies/xinfin.py @@ -51,9 +51,9 @@ class XinFin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/xuez.py b/hdwallet/cryptocurrencies/xuez.py index 3387efae..30da1acd 100644 --- a/hdwallet/cryptocurrencies/xuez.py +++ b/hdwallet/cryptocurrencies/xuez.py @@ -55,9 +55,9 @@ class XUEZ(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/ycash.py b/hdwallet/cryptocurrencies/ycash.py index 9015a80c..7f2ae764 100644 --- a/hdwallet/cryptocurrencies/ycash.py +++ b/hdwallet/cryptocurrencies/ycash.py @@ -55,9 +55,9 @@ class Ycash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/zcash.py b/hdwallet/cryptocurrencies/zcash.py index ef2fc732..f6b559b5 100644 --- a/hdwallet/cryptocurrencies/zcash.py +++ b/hdwallet/cryptocurrencies/zcash.py @@ -72,9 +72,9 @@ class Zcash(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/zclassic.py b/hdwallet/cryptocurrencies/zclassic.py index 0e687c9a..e8a912be 100644 --- a/hdwallet/cryptocurrencies/zclassic.py +++ b/hdwallet/cryptocurrencies/zclassic.py @@ -55,9 +55,9 @@ class ZClassic(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/zetacoin.py b/hdwallet/cryptocurrencies/zetacoin.py index 6ff35d0e..bd6e74f7 100644 --- a/hdwallet/cryptocurrencies/zetacoin.py +++ b/hdwallet/cryptocurrencies/zetacoin.py @@ -54,9 +54,9 @@ class Zetacoin(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/zilliqa.py b/hdwallet/cryptocurrencies/zilliqa.py index 7a4bc71a..32c3e6bd 100644 --- a/hdwallet/cryptocurrencies/zilliqa.py +++ b/hdwallet/cryptocurrencies/zilliqa.py @@ -51,9 +51,9 @@ class Zilliqa(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/cryptocurrencies/zoobc.py b/hdwallet/cryptocurrencies/zoobc.py index 23d4157c..82b02ff6 100644 --- a/hdwallet/cryptocurrencies/zoobc.py +++ b/hdwallet/cryptocurrencies/zoobc.py @@ -61,9 +61,9 @@ class ZooBC(ICryptocurrency): MNEMONICS = Mnemonics({ "BIP39" }) - SEEDS = Seeds({ - "BIP39" - }) + SEEDS = Seeds(( + "BIP39", "SLIP39" + )) HDS = HDs({ "BIP32", "BIP44" }) diff --git a/hdwallet/entropies/ientropy.py b/hdwallet/entropies/ientropy.py index 00a15ba3..34b01ae0 100644 --- a/hdwallet/entropies/ientropy.py +++ b/hdwallet/entropies/ientropy.py @@ -9,7 +9,7 @@ ) import os -import re +import string from ..exceptions import EntropyError from ..utils import ( @@ -104,9 +104,11 @@ def is_valid(cls, entropy: str) -> bool: :rtype: bool """ - return isinstance(entropy, str) and bool(re.fullmatch( - r'^[0-9a-fA-F]+$', entropy - )) and cls.is_valid_strength(len(entropy) * 4) + return ( + isinstance(entropy, str) + and all(c in string.hexdigits for c in entropy) + and cls.is_valid_strength(len(entropy) * 4) + ) @classmethod def is_valid_strength(cls, strength: int) -> bool: diff --git a/hdwallet/hdwallet.py b/hdwallet/hdwallet.py index abc32b90..7a67a140 100644 --- a/hdwallet/hdwallet.py +++ b/hdwallet/hdwallet.py @@ -441,22 +441,32 @@ def from_mnemonic(self, mnemonic: IMnemonic) -> "HDWallet": ).from_mnemonic( mnemonic=self._mnemonic.mnemonic() ) - return self.from_seed( - seed=SEEDS.seed( - name=( - "Cardano" if self._hd.name() == "Cardano" else self._mnemonic.name() + if self._hd.name() == "Cardano": + # We have to retain the specified Cardano seed type + return self.from_seed( + seed=SEEDS.seed( + name="Cardano" + ).__call__( + seed=seed, + cardano_type=self._cardano_type + ) + ) + else: + return self.from_seed( + seed=SEEDS.seed( + name=self._mnemonic.name() + ).__call__( + seed=seed ) - ).__call__( - seed=seed ) - ) - def from_seed(self, seed: ISeed) -> "HDWallet": + def from_seed(self, seed: Union[ISeed,bytes,str]) -> "HDWallet": """ Initialize the HDWallet from a seed. - :param seed: The seed instance to initialize the HD wallet. - :type seed: ISeed + + :param seed: The seed instance or data to initialize the HD wallet. + :type seed: Union[ISeed,bytes,str] :return: The initialized HDWallet instance. :rtype: HDWallet @@ -481,6 +491,26 @@ def from_seed(self, seed: ISeed) -> "HDWallet": | Monero | https://github.com/hdwallet-io/python-hdwallet/blob/master/examples/hdwallet/monero/from_seed.py | +----------------+-----------------------------------------------------------------------------------------------------------+ """ + if not isinstance(seed, ISeed): + # Convert raw hex seed data to the appropriate default ISeed for the HDWallet cryptocurrency. + # Certain Seed types require additional sub-type information + if type(seed) is bytes: + seed = seed.hex() + try: + seed_cls = SEEDS.seed( + name=self._cryptocurrency.SEEDS.get_seeds()[0] + ) + if seed_cls.name() == "Cardano": + seed = seed_cls( + seed=seed, + cardano_type=self._cardano_type + ) + else: + seed = seed_cls( + seed=seed + ) + except Exception as exc: + raise Error(f"Invalid seed for {self._cryptocurrency.NAME} cryptocurrency") from exc if seed.name() not in self._cryptocurrency.SEEDS.get_seeds(): raise Error(f"Invalid seed class {seed.name()} for {self._cryptocurrency.NAME} cryptocurrency") diff --git a/hdwallet/seeds/algorand.py b/hdwallet/seeds/algorand.py index c39efc99..f0c46957 100644 --- a/hdwallet/seeds/algorand.py +++ b/hdwallet/seeds/algorand.py @@ -4,7 +4,9 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or https://opensource.org/license/mit -from typing import Optional, Union +from typing import ( + List, Optional, Union +) from ..mnemonics import ( IMnemonic, AlgorandMnemonic @@ -23,7 +25,7 @@ class AlgorandSeed(ISeed): This class inherits from the ``ISeed`` class, thereby ensuring that all functions are accessible. """ - length = 64 + lengths: List[int] = [64] @classmethod def name(cls) -> str: diff --git a/hdwallet/seeds/bip39.py b/hdwallet/seeds/bip39.py index 906db9e9..fbe3f756 100644 --- a/hdwallet/seeds/bip39.py +++ b/hdwallet/seeds/bip39.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Optional, Union + List, Optional, Union ) import unicodedata @@ -37,7 +37,7 @@ class BIP39Seed(ISeed): seed_salt_modifier: str = "mnemonic" seed_pbkdf2_rounds: int = 2048 - length = 128 + lengths: List[int] = [128] @classmethod def name(cls) -> str: diff --git a/hdwallet/seeds/cardano.py b/hdwallet/seeds/cardano.py index a3079021..0e1d74d2 100644 --- a/hdwallet/seeds/cardano.py +++ b/hdwallet/seeds/cardano.py @@ -5,11 +5,11 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Optional, Union, List + List, Optional, Set, Union ) import cbor2 -import re +import string from ..mnemonics import ( IMnemonic, BIP39Mnemonic @@ -38,15 +38,26 @@ class CardanoSeed(ISeed): This class inherits from the ``ISeed`` class, thereby ensuring that all functions are accessible. """ + # According to https://cardano-c.readthedocs.io/en/stable/api/bip39.html#, Cardano supports + # english BIP-39 Mnemonics in lengths: + # + # - 16 bytes / 32 hex (128 bits) → 12 words + # - 20 bytes / 40 hex (160 bits) → 15 words + # - 24 bytes / 48 hex (192 bits) → 18 words + # - 28 bytes / 56 hex (224 bits) → 21 words + # - 32 bytes / 64 hex (256 bits) → 24 words _cardano_type: str lengths: List[int] = [ - 32, # Byron-Icarus and Shelly-Icarus - 128, # Byron-Ledger and Shelly-Ledger - 64 # Byron-Legacy + 32, # Byron-Icarus and Shelly-Icarus; any valid BIP-39 Entropy + 40, + 48, + 56, + 64, # Byron-Legacy; special Blake2B 256-bit encoding only + 128, # Byron-Ledger and Shelly-Ledger 512-bit BIP-39 encoding ] def __init__( - self, seed: str, cardano_type: str = Cardano.TYPES.BYRON_ICARUS, passphrase: Optional[str] = None + self, seed: str, cardano_type: str = Cardano.TYPES.BYRON_ICARUS ) -> None: """ Initialize a CardanoSeed object. @@ -55,22 +66,21 @@ def __init__( :type seed: str :param cardano_type: The type of Cardano seed. Defaults to Cardano.TYPES.BYRON_ICARUS. :type cardano_type: str, optional - - :param passphrase: Optional passphrase for deriving the seed. Defaults to None. - :type passphrase: str, optional """ - - super(CardanoSeed, self).__init__( - seed=seed, cardano_type=cardano_type, passphrase=passphrase - ) - - if cardano_type not in Cardano.TYPES.get_cardano_types(): - raise SeedError( - "Invalid Cardano type", expected=Cardano.TYPES.get_cardano_types(), got=cardano_type + try: + super().__init__( + seed=seed, cardano_type=cardano_type ) - + except Exception as exc: + raise SeedError( + f"Invalid {cardano_type} seed size", + expected=( + ", ".join(f"{nibbles*4}-" for nibbles in sorted(self.cardano_type_lengths(cardano_type))) + + "bit" + ), + got=f"{len(seed)*4}-bit" + ) from exc self._cardano_type = cardano_type - self._seed = seed @classmethod def name(cls) -> str: @@ -93,6 +103,16 @@ def cardano_type(self) -> str: return self._cardano_type + @classmethod + def cardano_type_lengths(cls, cardano_type) -> Set[int]: + if cardano_type in [Cardano.TYPES.BYRON_ICARUS, Cardano.TYPES.SHELLEY_ICARUS]: + return set(cls.lengths[:-1]) # BIP-39 Entropy required + elif cardano_type == Cardano.TYPES.BYRON_LEGACY: + return set(cls.lengths[-2:-1]) # Blake2B 256-bit hash require + elif cardano_type in [Cardano.TYPES.BYRON_LEDGER, Cardano.TYPES.SHELLEY_LEDGER]: + return set(cls.lengths[-1:]) # Raw BIP-39 512-bit encoded seed required + return set() + @classmethod def is_valid(cls, seed: str, cardano_type: str = Cardano.TYPES.BYRON_ICARUS) -> bool: """ @@ -106,22 +126,12 @@ def is_valid(cls, seed: str, cardano_type: str = Cardano.TYPES.BYRON_ICARUS) -> :return: True if is valid, False otherwise. :rtype: bool """ + if super().is_valid(seed): + # But also, specific Cardano types must have a specific seed length + if len(seed) in cls.cardano_type_lengths(cardano_type): + return True + return False - if not isinstance(seed, str) or not bool(re.fullmatch( - r'^[0-9a-fA-F]+$', seed - )): - return False - - if cardano_type in [Cardano.TYPES.BYRON_ICARUS, Cardano.TYPES.SHELLEY_ICARUS]: - return len(seed) == cls.lengths[0] - elif cardano_type in [Cardano.TYPES.BYRON_LEDGER, Cardano.TYPES.SHELLEY_LEDGER]: - return len(seed) == cls.lengths[1] - elif cardano_type == Cardano.TYPES.BYRON_LEGACY: - return len(seed) == cls.lengths[2] - else: - raise SeedError( - "Invalid Cardano type", expected=Cardano.TYPES.get_cardano_types(), got=cardano_type - ) @classmethod def from_mnemonic( @@ -149,7 +159,6 @@ def from_mnemonic( :return: The generated Cardano wallet seed as a string. :rtype: str """ - if cardano_type == Cardano.TYPES.BYRON_ICARUS: return cls.generate_byron_icarus(mnemonic=mnemonic, language=language) if cardano_type == Cardano.TYPES.BYRON_LEDGER: @@ -160,7 +169,7 @@ def from_mnemonic( return cls.generate_byron_legacy(mnemonic=mnemonic, language=language) if cardano_type == Cardano.TYPES.SHELLEY_ICARUS: return cls.generate_shelley_icarus(mnemonic=mnemonic, language=language) - elif cardano_type == Cardano.TYPES.SHELLEY_LEDGER: + if cardano_type == Cardano.TYPES.SHELLEY_LEDGER: return cls.generate_shelley_ledger( mnemonic=mnemonic, passphrase=passphrase, language=language ) diff --git a/hdwallet/seeds/electrum/v1.py b/hdwallet/seeds/electrum/v1.py index 308b0a7f..8a5575ad 100644 --- a/hdwallet/seeds/electrum/v1.py +++ b/hdwallet/seeds/electrum/v1.py @@ -4,7 +4,9 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or https://opensource.org/license/mit -from typing import Optional, Union +from typing import ( + List, Optional, Union +) from ...crypto import sha256 from ...mnemonics import ( @@ -27,7 +29,7 @@ class ElectrumV1Seed(ISeed): hash_iteration_number: int = 10 ** 5 - length = 64 + lengths: List[int] = [64] @classmethod def name(cls) -> str: diff --git a/hdwallet/seeds/electrum/v2.py b/hdwallet/seeds/electrum/v2.py index 9a67753c..b8bfc5b7 100644 --- a/hdwallet/seeds/electrum/v2.py +++ b/hdwallet/seeds/electrum/v2.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Optional, Union + List, Optional, Union ) import unicodedata @@ -32,7 +32,7 @@ class ElectrumV2Seed(ISeed): seed_salt_modifier: str = "electrum" seed_pbkdf2_rounds: int = 2048 - length = 128 + lengths: List[int] = [128] @classmethod def name(cls) -> str: diff --git a/hdwallet/seeds/iseed.py b/hdwallet/seeds/iseed.py index ce26298d..0f725652 100644 --- a/hdwallet/seeds/iseed.py +++ b/hdwallet/seeds/iseed.py @@ -7,23 +7,25 @@ from abc import ( ABC, abstractmethod ) -from typing import Optional, Union +from typing import ( + List, Optional, Union +) -import re +import string from ..mnemonics import IMnemonic - +from ..exceptions import SeedError class ISeed(ABC): _name: str _seed: str - length: int + lengths: List[int] # valid seed lengths, in hex symbols def __init__(self, seed: str, **kwargs) -> None: """ - Initialize an object with a seed value. + Initialize an object with a hex seed value. :param seed: The seed value used for initialization. :type seed: str @@ -31,7 +33,19 @@ def __init__(self, seed: str, **kwargs) -> None: :return: No return :rtype: NoneType """ - + if not self.is_valid(seed, **kwargs): + raise SeedError( + f"Invalid {self.name()} seed: {seed}", + expected=( + ", ".join(f"{nibbles*4}-" for nibbles in sorted(self.lengths)) + + "bit" + ), + got=( + f"{len(seed)*4}-bit " + + ("non-" if not all(c in string.hexdigits for c in seed) else "") + + "hex" + ) + ) self._seed = seed @classmethod @@ -50,9 +64,11 @@ def is_valid(cls, seed: str) -> bool: :rtype: bool """ - return isinstance(seed, str) and bool(re.fullmatch( - r'^[0-9a-fA-F]+$', seed - )) and len(seed) * 4 == cls.length + return ( + isinstance(seed, str) + and all(c in string.hexdigits for c in seed) + and len(seed) in set(cls.lengths) + ) def seed(self) -> str: """ diff --git a/hdwallet/seeds/monero.py b/hdwallet/seeds/monero.py index 185e9637..84ff76e6 100644 --- a/hdwallet/seeds/monero.py +++ b/hdwallet/seeds/monero.py @@ -4,7 +4,9 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or https://opensource.org/license/mit -from typing import Optional, Union +from typing import ( + List, Optional, Union +) from ..mnemonics import ( IMnemonic, MoneroMnemonic @@ -19,11 +21,13 @@ class MoneroSeed(ISeed): phrases and converting them into a binary seed used for hierarchical deterministic wallets. + Monero Mnemonic entropy is used directly as the private key, without modification. + .. note:: This class inherits from the ``ISeed`` class, thereby ensuring that all functions are accessible. """ - length = 32 + lengths: List[int] = [32, 64] @classmethod def name(cls) -> str: diff --git a/hdwallet/seeds/slip39.py b/hdwallet/seeds/slip39.py index 3094a798..28990d49 100644 --- a/hdwallet/seeds/slip39.py +++ b/hdwallet/seeds/slip39.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Optional, Union + List, Optional, Union ) from ..exceptions import EntropyError @@ -27,6 +27,8 @@ class SLIP39Seed(ISeed): This class inherits from the ``ISeed`` class, thereby ensuring that all functions are accessible. """ + lengths: List[int] = [32, 64, 128] + @classmethod def name(cls) -> str: """ diff --git a/tests/data/json/hdwallet.json b/tests/data/json/hdwallet.json index 3870fc76..ed3e26e9 100644 --- a/tests/data/json/hdwallet.json +++ b/tests/data/json/hdwallet.json @@ -1210,6 +1210,84 @@ } ] }, + "byron-ledger": { + "cryptocurrency": "Cardano", + "symbol": "ADA", + "network": "mainnet", + "coin_type": 1815, + "entropy": "b36caf4ad6e4f90b5a46a497f8dbeb6aa10ddfb517eaec1ceb08eb5fdfcc0bc3", + "strength": 256, + "mnemonic": "recall grace sport punch exhibit mad harbor stand obey short width stem awkward used stairs wool ugly trap season stove worth toward congress jaguar", + "passphrase": null, + "language": "english", + "seed": "c167860ff4b291173e28ba7e886b1b58723c8ccf4455e003b3c56b99516378701454db9b96a7574445d6a519bcf4af1fa770a0ea325151d808949e1a7148a461", + "ecc": "Kholaw-Ed25519", + "hd": "Cardano", + "cardano_type": "byron-ledger", + "semantic": "p2pkh", + "root_xprivate_key": "xprv3QESAWYc9vDdZaGUCzZVAaBmCAxico5f7sW35WCQFemzjQV5k9NeqFMWYyMdLLyhEKDPWSSZyFLecgMhdiQyE2ReAgfySR2V9bYfjiw2h6GojoRnEnvyAmSF7x7BXNwNZdRriMReX3da9tsRMMS591a", + "root_xpublic_key": "xpub661MyMwAqRbcFaX4J1Dep5qKMZxtLWXBZZLKfdADBxwXiaiM5QSz3NMTmbHgHUaFjzs87US9FNqJh9B9v2L46SWrMhdvUDLgFyvKfMoeXYX", + "root_private_key": "a08cf85b564ecf3b947d8d4321fb96d70ee7bb760877e371899b14e2ccf88658104b884682b57efd97decbb318a45c05a527b9cc5c2f64f7352935a049ceea60", + "root_chain_code": "680d52308194ccef2a18e6812b452a5815fbd7f5babc083856919aaf668fe7e4", + "root_public_key": "00c368c07566d1218d6dd2c7d945fe8b627f8eb6900dba953e112184cbd213b993", + "strict": true, + "derivations": [ + { + "at": { + "path": "m/1852'/1815'/0'/0/0", + "indexes": [ + 2147485500, + 2147485463, + 2147483648, + 0, + 0 + ], + "depth": 5, + "purpose": 1852, + "coin_type": 1815, + "account": 0, + "role": "external-chain", + "address": 0 + }, + "xprivate_key": "xprv3TKYfq8Acwj13LurBapHCv9dEps7QYpPKxZLmNnJDCEBDLRNicYTj6bHDfZ5uWQ7mQQCF3vqgsgX3ZTrgkMQg9ypnYn8J4CsMXDowrBJ53gLiUYy8jRCn5H35N3zVrexTx9eoF8LaMpT6xyA4qow8sL", + "xpublic_key": "xpub6GQJoKhgd9wFz885guVH5Qn5ZenBvQAddy7DmyVHnEhYebc2AYxzaMho79G8oXUNNtgNw32FwYtXYh4zE7qAjyeCpF9mLsZjK6Nc5zaAchG", + "private_key": "90c9771c3b6d3daaba283b315036cee82a000ccb4a6e6227e1c7b2f2e4f88658d96d9ecb0e9e605ce723779ad0d3388d9abb504b0fd63a5129593709d1394449", + "chain_code": "563f688471af3a2de595a30813bbe676b0bd5aa06e3615895e915e3459bc70d8", + "public_key": "007fe6111e969e68d02f1d1e588c4c8f22584189a2714f9239a28888e645a2ee8a", + "hash": "3c4d110808d5bcd54972ea86add1c77cc77d47d7", + "fingerprint": "3c4d1108", + "parent_fingerprint": "86556a41", + "address": "Ae2tdPwUPEYxN6HXvUwEjLK2CMYXqH5oxtbi3DwxabvUuBkFp6CyHstUXpb" + }, + { + "at": { + "path": "m/1852'/1815'/1'/0/0", + "indexes": [ + 2147485500, + 2147485463, + 2147483649, + 0, + 0 + ], + "depth": 5, + "purpose": 1852, + "coin_type": 1815, + "account": 1, + "role": "external-chain", + "address": 0 + }, + "xprivate_key": "xprv3T2gYJLSzeavxXR7cw6aCkdAMrxXr66p6kk7TuJRBALoLxn2LUdEAisKKUqT9oT4U3C35pR64EiJiTURXXnCWnLYGuka3nGnJJ2RGTA6WTYVBDFxX1v4TosbkVRm2eP8TnnRwHwAQv8DTBXfZr2PuQi", + "xpublic_key": "xpub6FRY7y2QEcx78ubr3KZpK7Xaaqxi7g11Z3H68v8hLn1QTLB9Whm3B1NJtMnyotY6EZZwBa1uw6u1XHGFJGxDr2MTmH8RooYCr46SuhGkZ8c", + "private_key": "b81ee3b3e8e13968c85cfd6e33a444ec3aece4d7686fc31de9e309e0e2f88658e01aeab75ea1bf697ce2dfa9bf3e0aa578b34e09d11678e709a6f37faf597dde", + "chain_code": "7f3e38be94e9504c91e91bf3ce247dace7b4c384c253969c48be6eb304a8454e", + "public_key": "007efddb16c762e60c6f4d3084bda0f5b67dc794d6a58c5b84730df93d4426fdce", + "hash": "72ed3a72afa082404b3841f0ca4a90764f9963fd", + "fingerprint": "72ed3a72", + "parent_fingerprint": "012ac401", + "address": "Ae2tdPwUPEZ9de6gCHgEG71kpA9yHZHKaunzKBUCxAwF8dhZ6oMZDjNpnv4" + } + ] + }, "byron-legacy": { "cryptocurrency": "Cardano", "symbol": "ADA", @@ -1822,4 +1900,4 @@ ] } } -} \ No newline at end of file +} diff --git a/tests/hdwallet/hdwallet/cardano/byron-icarus/test_cardano_byron_icarus_from_seed.py b/tests/hdwallet/hdwallet/cardano/byron-icarus/test_cardano_byron_icarus_from_seed.py index 1a48bdb0..2393383d 100644 --- a/tests/hdwallet/hdwallet/cardano/byron-icarus/test_cardano_byron_icarus_from_seed.py +++ b/tests/hdwallet/hdwallet/cardano/byron-icarus/test_cardano_byron_icarus_from_seed.py @@ -27,7 +27,8 @@ def test_byron_icarus_from_seed(data): address_type=cryptocurrency.ADDRESS_TYPES.PUBLIC_KEY ).from_seed( seed=CardanoSeed( - seed=data["hdwallet"]["Cardano"]["byron-icarus"]["seed"] + seed=data["hdwallet"]["Cardano"]["byron-icarus"]["seed"], + cardano_type=data["hdwallet"]["Cardano"]["byron-icarus"]["cardano_type"] ) ).from_derivation( derivation=DERIVATIONS.derivation(data["hdwallet"]["Cardano"]["derivation"]["name"])( diff --git a/tests/hdwallet/hdwallet/cardano/byron-ledger/test_cardano_byron_ledger_from_seed.py b/tests/hdwallet/hdwallet/cardano/byron-ledger/test_cardano_byron_ledger_from_seed.py index 291a435d..9763ae8a 100644 --- a/tests/hdwallet/hdwallet/cardano/byron-ledger/test_cardano_byron_ledger_from_seed.py +++ b/tests/hdwallet/hdwallet/cardano/byron-ledger/test_cardano_byron_ledger_from_seed.py @@ -27,7 +27,8 @@ def test_byron_ledger_from_seed(data): address_type=cryptocurrency.ADDRESS_TYPES.PUBLIC_KEY ).from_seed( seed=CardanoSeed( - seed=data["hdwallet"]["Cardano"]["byron-ledger"]["seed"] + seed=data["hdwallet"]["Cardano"]["byron-ledger"]["seed"], + cardano_type=data["hdwallet"]["Cardano"]["byron-ledger"]["cardano_type"] ) ).from_derivation( derivation=DERIVATIONS.derivation(data["hdwallet"]["Cardano"]["derivation"]["name"])( diff --git a/tests/hdwallet/hdwallet/cardano/byron-legacy/test_cardano_byron_legacy_from_seed.py b/tests/hdwallet/hdwallet/cardano/byron-legacy/test_cardano_byron_legacy_from_seed.py index 009da13a..5be70af5 100644 --- a/tests/hdwallet/hdwallet/cardano/byron-legacy/test_cardano_byron_legacy_from_seed.py +++ b/tests/hdwallet/hdwallet/cardano/byron-legacy/test_cardano_byron_legacy_from_seed.py @@ -27,7 +27,8 @@ def test_byron_legacy_from_seed(data): address_type=cryptocurrency.ADDRESS_TYPES.PUBLIC_KEY ).from_seed( seed=CardanoSeed( - seed=data["hdwallet"]["Cardano"]["byron-legacy"]["seed"] + seed=data["hdwallet"]["Cardano"]["byron-legacy"]["seed"], + cardano_type=data["hdwallet"]["Cardano"]["byron-legacy"]["cardano_type"], ) ).from_derivation( derivation=DERIVATIONS.derivation(data["hdwallet"]["Cardano"]["derivation"]["name"])( diff --git a/tests/hdwallet/hdwallet/cardano/shelley-icarus/test_cardano_shelley_icarus_from_seed.py b/tests/hdwallet/hdwallet/cardano/shelley-icarus/test_cardano_shelley_icarus_from_seed.py index 75a581ea..35bdd023 100644 --- a/tests/hdwallet/hdwallet/cardano/shelley-icarus/test_cardano_shelley_icarus_from_seed.py +++ b/tests/hdwallet/hdwallet/cardano/shelley-icarus/test_cardano_shelley_icarus_from_seed.py @@ -27,7 +27,8 @@ def test_shelley_icarus_from_seed(data): address_type=cryptocurrency.ADDRESS_TYPES.STAKING ).from_seed( seed=CardanoSeed( - seed=data["hdwallet"]["Cardano"]["shelley-icarus"]["seed"] + seed=data["hdwallet"]["Cardano"]["shelley-icarus"]["seed"], + cardano_type=data["hdwallet"]["Cardano"]["shelley-icarus"]["cardano_type"] ) ).from_derivation( derivation=DERIVATIONS.derivation(data["hdwallet"]["Cardano"]["derivation"]["name"])( diff --git a/tests/hdwallet/hdwallet/cardano/shelley-ledger/test_cardano_shelley_ledger_from_seed.py b/tests/hdwallet/hdwallet/cardano/shelley-ledger/test_cardano_shelley_ledger_from_seed.py index 62a5e415..1a647f92 100644 --- a/tests/hdwallet/hdwallet/cardano/shelley-ledger/test_cardano_shelley_ledger_from_seed.py +++ b/tests/hdwallet/hdwallet/cardano/shelley-ledger/test_cardano_shelley_ledger_from_seed.py @@ -27,7 +27,8 @@ def test_shelley_ledger_from_seed(data): address_type=cryptocurrency.ADDRESS_TYPES.STAKING ).from_seed( seed=CardanoSeed( - seed=data["hdwallet"]["Cardano"]["shelley-ledger"]["seed"] + seed=data["hdwallet"]["Cardano"]["shelley-ledger"]["seed"], + cardano_type=data["hdwallet"]["Cardano"]["shelley-ledger"]["cardano_type"], ) ).from_derivation( derivation=DERIVATIONS.derivation(data["hdwallet"]["Cardano"]["derivation"]["name"])( diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 00000000..d04be880 --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import pytest +from pathlib import Path + + +# Discover all clientscripts +CLIENTS_DIR = Path(__file__).parent.parent / "clients" +CLIENTS_SCRIPTS = sorted(CLIENTS_DIR.rglob("*.py")) + +# Project root directory (for PYTHONPATH) +PROJECT_ROOT = Path(__file__).parent.parent + + +@pytest.mark.clients +@pytest.mark.parametrize("script_path", CLIENTS_SCRIPTS, ids=lambda p: str(p.relative_to(CLIENTS_DIR))) +def test_client_script_runs(script_path): + """Test that clients scripts execute without raising exceptions.""" + # Set PYTHONPATH to use local source instead of installed package + env = os.environ.copy() + env["PYTHONPATH"] = str(PROJECT_ROOT) + + result = subprocess.run( + ["python3", str(script_path)], + capture_output=True, + text=True, + timeout=30, + env=env + ) + print(result.stdout) + assert result.returncode == 0, ( + f"Script {script_path.name} failed with exit code {result.returncode}\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) From 132aeffaf99093bd79c7933f9f636442354db26b Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sat, 22 Nov 2025 06:50:18 -0700 Subject: [PATCH 38/38] Allow CustomDerivation for BIP44/49/84 HDs, and path support for Derivations --- hdwallet/cryptocurrencies/litecoin.py | 2 +- hdwallet/derivations/bip44.py | 29 ++++++++++++++++++++++++--- hdwallet/derivations/iderivation.py | 3 ++- hdwallet/hds/bip32.py | 5 ++--- hdwallet/hds/bip44.py | 10 ++++----- hdwallet/hds/bip49.py | 8 ++++---- hdwallet/hds/bip84.py | 8 ++++---- hdwallet/hds/bip86.py | 8 ++++---- hdwallet/hdwallet.py | 13 +++++++++++- 9 files changed, 60 insertions(+), 26 deletions(-) diff --git a/hdwallet/cryptocurrencies/litecoin.py b/hdwallet/cryptocurrencies/litecoin.py index fc6fef72..b0b0621e 100644 --- a/hdwallet/cryptocurrencies/litecoin.py +++ b/hdwallet/cryptocurrencies/litecoin.py @@ -101,7 +101,7 @@ class Litecoin(ICryptocurrency): "BIP39", "SLIP39" )) HDS = HDs({ - "BIP32", "BIP44", "BIP84" + "BIP32", "BIP44", "BIP49", "BIP84" }) DEFAULT_HD = HDS.BIP44 DEFAULT_PATH = f"m/44'/{COIN_TYPE}'/0'/0/0" diff --git a/hdwallet/derivations/bip44.py b/hdwallet/derivations/bip44.py index 80c9a42e..4a0fee8e 100644 --- a/hdwallet/derivations/bip44.py +++ b/hdwallet/derivations/bip44.py @@ -5,7 +5,7 @@ # file COPYING or https://opensource.org/license/mit from typing import ( - Tuple, Union + List, Tuple, Optional, Union ) from ..utils import ( @@ -51,7 +51,9 @@ def __init__( coin_type: Union[str, int] = Bitcoin.COIN_TYPE, account: Union[str, int, Tuple[int, int]] = 0, change: Union[str, int] = CHANGES.EXTERNAL_CHAIN, - address: Union[str, int, Tuple[int, int]] = 0 + address: Union[str, int, Tuple[int, int]] = 0, + path: Optional[str] = None, + indexes: Optional[List[int]] = None, ) -> None: """ Initialize a BIP44 derivation path with specified parameters. @@ -64,10 +66,31 @@ def __init__( :type change: Union[str, int] :param address: The BIP44 address index or tuple. Defaults to 0. :type address: Union[str, int, Tuple[int, int]] + :param path: Optional derivation path string. + :type path: Optional[str] + :param indexes: Optional list of derivation indexes. + :type indexes: Optional[List[int]] :return: None """ - super(BIP44Derivation, self).__init__() + super(BIP44Derivation, self).__init__(path=path, indexes=indexes) + if len(self._indexes) >= 1: + if self._derivations[0] != self._purpose: + raise DerivationError( + "Incorrect derivation path Purpose", expected=self._purpose, got=self._derivations[0] + ) + if len(self._indexes) >= 2: + coin_type = self._derivations[1][0] + if len(self._indexes) >= 3: + account = self._derivations[2][0] + if len(self._indexes) >= 4: + change = self._derivations[3][0] + if len(self._indexes) >= 5: + address = self._derivations[4][0] + if len(self._indexes) > 5: + raise DerivationError( + "Incorrect number of derivation path segments", expected="<= 5", got=len(self._indexes) + ) self._coin_type = normalize_index(index=coin_type, hardened=True) self._account = normalize_index(index=account, hardened=True) diff --git a/hdwallet/derivations/iderivation.py b/hdwallet/derivations/iderivation.py index 4c981537..1377ff0f 100644 --- a/hdwallet/derivations/iderivation.py +++ b/hdwallet/derivations/iderivation.py @@ -21,7 +21,8 @@ def __init__( self, path: Optional[str] = None, indexes: Optional[List[int]] = None, **kwargs ) -> None: """ - Initializes an object for iderivation. + Initializes an object for iderivation. Derived classes may support different + arguments, but should always support path and indexes. :param path: Optional derivation path string. :type path: Optional[str] diff --git a/hdwallet/hds/bip32.py b/hdwallet/hds/bip32.py index c35158e2..426ec6ea 100644 --- a/hdwallet/hds/bip32.py +++ b/hdwallet/hds/bip32.py @@ -411,14 +411,13 @@ def clean_derivation(self) -> "BIP32HD": self._root_private_key, self._root_chain_code, (integer_to_bytes(0x00) * 4) ) self._public_key = self._private_key.public_key() - self._derivation.clean() - self._depth = 0 elif self._root_public_key: self._public_key, self._chain_code, self._parent_fingerprint = ( self._root_public_key, self._root_chain_code, (integer_to_bytes(0x00) * 4) ) + if self._derivation: self._derivation.clean() - self._depth = 0 + self._depth = 0 return self def drive(self, index: int) -> Optional["BIP32HD"]: diff --git a/hdwallet/hds/bip44.py b/hdwallet/hds/bip44.py index d46ba965..006b26c9 100644 --- a/hdwallet/hds/bip44.py +++ b/hdwallet/hds/bip44.py @@ -14,14 +14,14 @@ from ..addresses import P2PKHAddress from ..exceptions import DerivationError from ..derivations import ( - IDerivation, BIP44Derivation + IDerivation, BIP44Derivation, CustomDerivation ) from .bip32 import BIP32HD class BIP44HD(BIP32HD): - _derivation: BIP44Derivation + _derivation: Union[BIP44Derivation, CustomDerivation] def __init__( self, ecc: Type[IEllipticCurveCryptography], public_key_type: str = PUBLIC_KEY_TYPES.COMPRESSED, **kwargs @@ -134,16 +134,16 @@ def from_derivation(self, derivation: IDerivation) -> "BIP44HD": """ Initialize the BIP44HD object from a given derivation. - :param derivation: The BIP44 derivation object. + :param derivation: The BIP44-compatible derivation object. :type derivation: IDerivation :return: Updated BIP44HD object. :rtype: BIP44HD """ - if not isinstance(derivation, BIP44Derivation): + if not isinstance(derivation, (BIP44Derivation, CustomDerivation)): raise DerivationError( - "Invalid derivation instance", expected=BIP44Derivation, got=type(derivation) + "Invalid derivation instance", expected={BIP44Derivation, CustomDerivation}, got=type(derivation) ) self.clean_derivation() diff --git a/hdwallet/hds/bip49.py b/hdwallet/hds/bip49.py index f109d205..6aef9c64 100644 --- a/hdwallet/hds/bip49.py +++ b/hdwallet/hds/bip49.py @@ -14,14 +14,14 @@ from ..addresses import P2WPKHInP2SHAddress from ..exceptions import DerivationError from ..derivations import ( - IDerivation, BIP49Derivation + IDerivation, BIP49Derivation, CustomDerivation ) from .bip44 import BIP44HD class BIP49HD(BIP44HD): - _derivation: BIP49Derivation + _derivation: Union[BIP49Derivation, CustomDerivation] def __init__( self, ecc: Type[IEllipticCurveCryptography], public_key_type: str = PUBLIC_KEY_TYPES.COMPRESSED, **kwargs @@ -67,9 +67,9 @@ def from_derivation(self, derivation: IDerivation) -> "BIP49HD": :rtype: BIP49HD """ - if not isinstance(derivation, BIP49Derivation): + if not isinstance(derivation, (BIP49Derivation, CustomDerivation)): raise DerivationError( - "Invalid derivation instance", expected=BIP49Derivation, got=type(derivation) + "Invalid derivation instance", expected=(BIP49Derivation, CustomDerivation), got=type(derivation) ) self.clean_derivation() diff --git a/hdwallet/hds/bip84.py b/hdwallet/hds/bip84.py index b54ba7a7..10dde53e 100644 --- a/hdwallet/hds/bip84.py +++ b/hdwallet/hds/bip84.py @@ -14,14 +14,14 @@ from ..addresses import P2WPKHAddress from ..exceptions import DerivationError from ..derivations import ( - IDerivation, BIP84Derivation + IDerivation, BIP84Derivation, CustomDerivation ) from .bip44 import BIP44HD class BIP84HD(BIP44HD): - _derivation: BIP84Derivation + _derivation: Union[BIP84Derivation, CustomDerivation] def __init__( self, ecc: Type[IEllipticCurveCryptography], public_key_type: str = PUBLIC_KEY_TYPES.COMPRESSED, **kwargs @@ -68,9 +68,9 @@ def from_derivation(self, derivation: IDerivation) -> "BIP84HD": :rtype: BIP84HD """ - if not isinstance(derivation, BIP84Derivation): + if not isinstance(derivation, (BIP84Derivation, CustomDerivation)): raise DerivationError( - "Invalid derivation instance", expected=BIP84Derivation, got=type(derivation) + "Invalid derivation instance", expected=(BIP84Derivation, CustomDerivation), got=type(derivation) ) self.clean_derivation() diff --git a/hdwallet/hds/bip86.py b/hdwallet/hds/bip86.py index 5cde29de..778ff68c 100644 --- a/hdwallet/hds/bip86.py +++ b/hdwallet/hds/bip86.py @@ -14,14 +14,14 @@ from ..addresses import P2TRAddress from ..exceptions import DerivationError from ..derivations import ( - IDerivation, BIP86Derivation + IDerivation, BIP86Derivation, CustomDerivation ) from .bip44 import BIP44HD class BIP86HD(BIP44HD): - _derivation: BIP86Derivation + _derivation: Union[BIP86Derivation, CustomDerivation] def __init__( self, ecc: Type[IEllipticCurveCryptography], public_key_type: str = PUBLIC_KEY_TYPES.COMPRESSED, **kwargs @@ -66,9 +66,9 @@ def from_derivation(self, derivation: IDerivation) -> "BIP86HD": :rtype: BIP86HD """ - if not isinstance(derivation, BIP86Derivation): + if not isinstance(derivation, (BIP86Derivation, CustomDerivation)): raise DerivationError( - "Invalid derivation instance", expected=BIP86Derivation, got=type(derivation) + "Invalid derivation instance", expected=(BIP86Derivation, CustomDerivation), got=type(derivation) ) self.clean_derivation() diff --git a/hdwallet/hdwallet.py b/hdwallet/hdwallet.py index 7a67a140..eedfe333 100644 --- a/hdwallet/hdwallet.py +++ b/hdwallet/hdwallet.py @@ -618,6 +618,16 @@ def from_xpublic_key(self, xpublic_key: str, encoded: bool = True, strict: bool ) return self + def from_path(self, path: Optional[str]) -> "HDWallet": + """ + Use the existing derivation, but from the provided path. + """ + return self.from_derivation( + self._derivation.__class__( + path=path + ) + ) + def from_derivation(self, derivation: IDerivation) -> "HDWallet": """ Initialize the HDWallet from a derivation object. @@ -657,7 +667,8 @@ def clean_derivation(self) -> "HDWallet": """ self._hd.clean_derivation() - self._derivation.clean() + if self._derivation: + self._derivation.clean() return self def from_private_key(self, private_key: str) -> "HDWallet":