From c8788e9f151ad552d124dbf58a2fff76e6b049fe Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sun, 28 Dec 2025 19:23:16 -0700 Subject: [PATCH 01/10] protocol initial; needs de-ai --- Makefile | 4 +- src/bipsea/apps/__init__.py | 22 +++++ src/bipsea/apps/base64/app.py | 55 +++++++++++ src/bipsea/apps/base85/app.py | 55 +++++++++++ src/bipsea/apps/dice/app.py | 80 ++++++++++++++++ src/bipsea/apps/hex/app.py | 55 +++++++++++ src/bipsea/apps/mnemonic/app.py | 109 ++++++++++++++++++++++ src/bipsea/apps/shared.py | 4 + src/bipsea/apps/wif/app.py | 48 ++++++++++ src/bipsea/apps/xprv/app.py | 51 +++++++++++ src/bipsea/bip85.py | 158 +++++++------------------------- src/bipsea/constants.py | 3 + src/bipsea/drng.py | 14 +++ src/bipsea/protocol.py | 45 +++++++++ src/bipsea/registry.py | 16 ++++ tests/test_bip85.py | 93 +++---------------- tests/test_cli.py | 80 +++++++++------- 17 files changed, 654 insertions(+), 238 deletions(-) create mode 100644 src/bipsea/apps/__init__.py create mode 100644 src/bipsea/apps/base64/app.py create mode 100644 src/bipsea/apps/base85/app.py create mode 100644 src/bipsea/apps/dice/app.py create mode 100644 src/bipsea/apps/hex/app.py create mode 100644 src/bipsea/apps/mnemonic/app.py create mode 100644 src/bipsea/apps/shared.py create mode 100644 src/bipsea/apps/wif/app.py create mode 100644 src/bipsea/apps/xprv/app.py create mode 100644 src/bipsea/constants.py create mode 100644 src/bipsea/drng.py create mode 100644 src/bipsea/protocol.py create mode 100644 src/bipsea/registry.py diff --git a/Makefile b/Makefile index e4d712e9..52bba732 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ install-dist:: check:: poetry run black . --check poetry run isort . --check - poetry run flake8 . --ignore=E501,W503 + poetry run flake8 . --ignore=E501,W503,E704 bash -n scripts/*.sh bash -n tests/*.sh @@ -55,7 +55,7 @@ lint:: isort . black . actionlint - flake8 . --ignore=E501,W503 + flake8 . --ignore=E501,W503,E704 checkmake Makefile git-off-main:: diff --git a/src/bipsea/apps/__init__.py b/src/bipsea/apps/__init__.py new file mode 100644 index 00000000..cc2dad1b --- /dev/null +++ b/src/bipsea/apps/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Dict + +from bipsea.apps.base64.app import app as base64_app +from bipsea.apps.base85.app import app as base85_app +from bipsea.apps.dice.app import app as dice_app +from bipsea.apps.hex.app import app as hex_app +from bipsea.apps.mnemonic.app import app as mnemonic_app +from bipsea.apps.wif.app import app as wif_app +from bipsea.apps.xprv.app import app as xprv_app +from bipsea.protocol import BIP85App + +APPS: Dict[str, BIP85App] = { + base64_app.name: base64_app, + base85_app.name: base85_app, + dice_app.name: dice_app, + hex_app.name: hex_app, + mnemonic_app.name: mnemonic_app, + wif_app.name: wif_app, + xprv_app.name: xprv_app, +} diff --git a/src/bipsea/apps/base64/app.py b/src/bipsea/apps/base64/app.py new file mode 100644 index 00000000..77f36252 --- /dev/null +++ b/src/bipsea/apps/base64/app.py @@ -0,0 +1,55 @@ +import base64 as b64 +from typing import Any + +from bipsea.apps.shared import hardened_int +from bipsea.protocol import Param, TestVector + + +class Base64App: + name = "base64" + code = "707764'" + + @property + def number_range(self) -> tuple[int, int]: + return (20, 86) + + @property + def params(self) -> list[Param]: + return [ + Param( + "length", + "-n", + int, + required=True, + range=(20, 86), + help="Password length in characters", + ), + ] + + def path_segments(self, index: int, length: int, **_) -> list[str]: + return [f"{length}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {"length": hardened_int(segments[0])} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + if not (20 <= length <= 86): + raise ValueError(f"Expected length in [20, 86], got {length}") + return { + "entropy": entropy, + "application": b64.b64encode(entropy).decode("utf-8")[:length], + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/707764'/21'/0'", + entropy="74a2e87a9ba0cdd549bdd2f9ea880d554c6c355b08ed25088cfa88f3f1c4f74632b652fd4a8f5fda43074c6f6964a3753b08bb5210c8f5e75c07a4c2a20bf6e9", + output="dKLoepugzdVJvdL56ogNV", + ), + ] + + +app = Base64App() diff --git a/src/bipsea/apps/base85/app.py b/src/bipsea/apps/base85/app.py new file mode 100644 index 00000000..62939103 --- /dev/null +++ b/src/bipsea/apps/base85/app.py @@ -0,0 +1,55 @@ +import base64 as b64 +from typing import Any + +from bipsea.apps.shared import hardened_int +from bipsea.protocol import Param, TestVector + + +class Base85App: + name = "base85" + code = "707785'" + + @property + def number_range(self) -> tuple[int, int]: + return (10, 80) + + @property + def params(self) -> list[Param]: + return [ + Param( + "length", + "-n", + int, + required=True, + range=(10, 80), + help="Password length in characters", + ), + ] + + def path_segments(self, index: int, length: int, **_) -> list[str]: + return [f"{length}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {"length": hardened_int(segments[0])} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + if not (10 <= length <= 80): + raise ValueError(f"Expected length in [10, 80], got {length}") + return { + "entropy": entropy, + "application": b64.b85encode(entropy).decode("utf-8")[:length], + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/707785'/12'/0'", + entropy="f7cfe56f63dca2490f65fcbf9ee63dcd85d18f751b6b5e1c1b8733af6459c904a75e82b4a22efff9b9e69de2144b293aa8714319a054b6cb55826a8e51425209", + output="_s`{TW89)i4`", + ), + ] + + +app = Base85App() diff --git a/src/bipsea/apps/dice/app.py b/src/bipsea/apps/dice/app.py new file mode 100644 index 00000000..00f8eff3 --- /dev/null +++ b/src/bipsea/apps/dice/app.py @@ -0,0 +1,80 @@ +import math +from typing import Any + +from bipsea.apps.shared import hardened_int +from bipsea.drng import DRNG +from bipsea.protocol import Param, TestVector + + +class DiceApp: + name = "dice" + code = "89101'" + + @property + def number_range(self) -> tuple[int, int]: + return (1, 10_000) + + @property + def params(self) -> list[Param]: + return [ + Param( + "rolls", + "-n", + int, + required=True, + range=(1, 10_000), + help="Number of rolls", + ), + Param( + "sides", + "-s", + int, + default=6, + range=(2, None), + help="Number of sides on die", + ), + ] + + def path_segments(self, index: int, rolls: int, sides: int = 6, **_) -> list[str]: + return [f"{sides}'", f"{rolls}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return { + "sides": hardened_int(segments[0]), + "rolls": hardened_int(segments[1]), + } + + def apply(self, entropy: bytes, rolls: int, sides: int = 6, **_) -> dict[str, Any]: + return { + "entropy": entropy, + "application": self._do_rolls(entropy, sides, rolls), + } + + def _do_rolls(self, entropy: bytes, sides: int, rolls: int) -> str: + max_width = len(str(sides - 1)) + history = [] + bits_per_roll = math.ceil(math.log(sides, 2)) + bytes_per_roll = math.ceil(bits_per_roll / 8) + drng = DRNG(entropy) + while len(history) < rolls: + trial_int = int.from_bytes(drng.read(bytes_per_roll), "big") + available_bits = 8 * bytes_per_roll + excess_bits = available_bits - bits_per_roll + trial_int >>= excess_bits + if trial_int < sides: + history.append(f"{trial_int:0{max_width}d}") + return ",".join(history) + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/89101'/6'/10'/0'", + entropy="5e41f8f5d5d9ac09a20b8a5797a3172b28c806aead00d27e36609e2dd116a59176a738804236586f668da8a51b90c708a4226d7f92259c69f64c51124b6f6cd2", + output="1,0,0,2,0,1,5,5,2,4", + ), + ] + + +app = DiceApp() diff --git a/src/bipsea/apps/hex/app.py b/src/bipsea/apps/hex/app.py new file mode 100644 index 00000000..fd7a4c48 --- /dev/null +++ b/src/bipsea/apps/hex/app.py @@ -0,0 +1,55 @@ +from typing import Any + +from bipsea.apps.shared import hardened_int +from bipsea.protocol import Param, TestVector +from bipsea.util import to_hex_string + + +class HexApp: + name = "hex" + code = "128169'" + + @property + def number_range(self) -> tuple[int, int]: + return (16, 64) + + @property + def params(self) -> list[Param]: + return [ + Param( + "length", + "-n", + int, + required=True, + range=(16, 64), + help="Output length in bytes", + ), + ] + + def path_segments(self, index: int, length: int, **_) -> list[str]: + return [f"{length}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {"length": hardened_int(segments[0])} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + if not (16 <= length <= 64): + raise ValueError(f"Expected length in [16, 64], got {length}") + return { + "entropy": entropy, + "application": to_hex_string(entropy[:length]), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/128169'/64'/0'", + entropy="492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e71aa2d645442f878555d078fd1f1f67e368976f04137b1f7a0d19232136ca50c44614af72b5582a5c", + output="492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e71aa2d645442f878555d078fd1f1f67e368976f04137b1f7a0d19232136ca50c44614af72b5582a5c", + ), + ] + + +app = HexApp() diff --git a/src/bipsea/apps/mnemonic/app.py b/src/bipsea/apps/mnemonic/app.py new file mode 100644 index 00000000..90e1ef46 --- /dev/null +++ b/src/bipsea/apps/mnemonic/app.py @@ -0,0 +1,109 @@ +from typing import Any + +from bipsea.apps.shared import hardened_int +from bipsea.bip39 import ( + LANGUAGES, + N_WORDS_META, + entropy_to_words, + validate_mnemonic_words, +) +from bipsea.protocol import Param, TestVector + +INDEX_TO_LANGUAGE = { + "0'": "english", + "1'": "japanese", + "2'": "korean", + "3'": "spanish", + "4'": "chinese_simplified", + "5'": "chinese_traditional", + "6'": "french", + "7'": "italian", + "8'": "czech", + "9'": "portuguese", # not in BIP-85 but in BIP-39 test vectors +} + +LANGUAGE_TO_INDEX = {v: k for k, v in INDEX_TO_LANGUAGE.items()} + +assert set(INDEX_TO_LANGUAGE.values()) == set(LANGUAGES.keys()) + + +class MnemonicApp: + name = "mnemonic" + code = "39'" + + @property + def number_range(self) -> None: + return None + + @property + def params(self) -> list[Param]: + return [ + Param( + "n_words", + "-n", + int, + required=True, + choices=[str(n) for n in N_WORDS_META.keys()], + help="Number of mnemonic words", + ), + Param( + "language", + "-t", + str, + default="english", + choices=list(LANGUAGES.keys()), + help="Output language", + ), + ] + + def path_segments( + self, index: int, n_words: int, language: str = "english", **_ + ) -> list[str]: + lang_code = LANGUAGE_TO_INDEX[language] + return [lang_code, f"{n_words}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return { + "language": INDEX_TO_LANGUAGE[segments[0]], + "n_words": hardened_int(segments[1]), + } + + def apply( + self, entropy: bytes, n_words: int, language: str = "english", **_ + ) -> dict[str, Any]: + if n_words not in N_WORDS_META: + raise ValueError(f"Unsupported number of words: {n_words}") + n_bytes = N_WORDS_META[n_words]["entropy_bits"] // 8 + trimmed = entropy[:n_bytes] + words = entropy_to_words(n_words, trimmed, language) + assert validate_mnemonic_words(words, language) + return { + "entropy": trimmed, + "application": " ".join(words), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/39'/0'/12'/0'", + entropy="6250b68daf746d12a24d58b4787a714b", + output="girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose", + ), + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/39'/0'/18'/0'", + entropy="938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc", + output="near account window bike charge season chef number sketch tomorrow excuse sniff circle vital hockey outdoor supply token", + ), + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/39'/0'/24'/0'", + entropy="ae131e2312cdc61331542efe0d1077bac5ea803adf24b313a4f0e48e9c51f37f", + output="puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano", + ), + ] + + +app = MnemonicApp() diff --git a/src/bipsea/apps/shared.py b/src/bipsea/apps/shared.py new file mode 100644 index 00000000..e7bd26a8 --- /dev/null +++ b/src/bipsea/apps/shared.py @@ -0,0 +1,4 @@ +def hardened_int(segment: str) -> int: + if not segment.endswith("'"): + raise ValueError(f"Expected hardened segment, got {segment}") + return int(segment[:-1]) diff --git a/src/bipsea/apps/wif/app.py b/src/bipsea/apps/wif/app.py new file mode 100644 index 00000000..ebfe84eb --- /dev/null +++ b/src/bipsea/apps/wif/app.py @@ -0,0 +1,48 @@ +from typing import Any + +import base58 + +from bipsea.protocol import Param, TestVector + + +class WifApp: + name = "wif" + code = "2'" + + @property + def number_range(self) -> None: + return None + + @property + def params(self) -> list[Param]: + return [] + + def path_segments(self, index: int, **_) -> list[str]: + return [f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {} + + def apply(self, entropy: bytes, network: str = "mainnet", **_) -> dict[str, Any]: + trimmed = entropy[:32] + prefix = b"\x80" if network == "mainnet" else b"\xef" + suffix = b"\x01" # use with compressed public keys because BIP-32 + extended = prefix + trimmed + suffix + return { + "entropy": trimmed, + "application": base58.b58encode_check(extended).decode("utf-8"), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/2'/0'", + entropy="7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1", + output="Kzyv4uF39d4Jrw2W7UryTHwZr1zQVNk4dAFyqE6BuMrMh1Za7uhp", + ), + ] + + +app = WifApp() diff --git a/src/bipsea/apps/xprv/app.py b/src/bipsea/apps/xprv/app.py new file mode 100644 index 00000000..f3767656 --- /dev/null +++ b/src/bipsea/apps/xprv/app.py @@ -0,0 +1,51 @@ +from typing import Any + +from bipsea.bip32 import VERSIONS, ExtendedKey +from bipsea.protocol import Param, TestVector + + +class XprvApp: + name = "xprv" + code = "32'" + + @property + def number_range(self) -> None: + return None + + @property + def params(self) -> list[Param]: + return [] + + def path_segments(self, index: int, **_) -> list[str]: + return [f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {} + + def apply(self, entropy: bytes, **_) -> dict[str, Any]: + derived_key = ExtendedKey( + version=VERSIONS["mainnet"]["private"], + depth=bytes(1), + finger=bytes(4), + child_number=bytes(4), + chain_code=entropy[:32], + data=bytes(1) + entropy[32:], + ) + return { + "entropy": entropy[32:], + "application": str(derived_key), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/32'/0'", + entropy="ead0b33988a616cf6a497f1c169d9e92562604e38305ccd3fc96f2252c177682", + output="xprv9s21ZrQH143K2srSbCSg4m4kLvPMzcWydgmKEnMmoZUurYuBuYG46c6P71UGXMzmriLzCCBvKQWBUv3vPB3m1SATMhp3uEjXHJ42jFg7myX", + ), + ] + + +app = XprvApp() diff --git a/src/bipsea/bip85.py b/src/bipsea/bip85.py index f637fb61..7973cd2d 100644 --- a/src/bipsea/bip85.py +++ b/src/bipsea/bip85.py @@ -1,57 +1,42 @@ -import base64 -import hashlib import logging import math import re from typing import Dict, Union -import base58 - -from .bip32 import VERSIONS, ExtendedKey +from .apps import APPS +from .apps.mnemonic.app import INDEX_TO_LANGUAGE +from .bip32 import ExtendedKey from .bip32 import derive_key as derive_key_bip32 from .bip32 import hmac_sha512 -from .bip39 import LANGUAGES, N_WORDS_META, entropy_to_words, validate_mnemonic_words -from .util import LOGGER_NAME, to_hex_string +from .constants import HMAC_KEY, PURPOSE_CODES +from .drng import DRNG +from .util import LOGGER_NAME logger = logging.getLogger(LOGGER_NAME) +__all__ = [ + "APPLICATIONS", + "RANGES", + "PURPOSE_CODES", + "HMAC_KEY", + "INDEX_TO_LANGUAGE", + "DRNG", + "apply_85", + "to_entropy", + "derive", + "split_and_validate", + "do_rolls", +] -APPLICATIONS = { - "base64": "707764'", - "base85": "707785'", - "dice": "89101'", - "drng": "0'", - "hex": "128169'", - "mnemonic": "39'", - "wif": "2'", - "xprv": "32'", -} -RANGES = { - "base64": (20, 86), - "base85": (10, 80), - "hex": (16, 64), - "dice": (1, 10_000), -} +APPLICATIONS = {app.name: app.code for app in APPS.values()} +APPLICATIONS["drng"] = "0'" -PURPOSE_CODES = {"BIP-85": "83696968'"} - -HMAC_KEY = b"bip-entropy-from-k" - -INDEX_TO_LANGUAGE = { - "0'": "english", - "1'": "japanese", - "2'": "korean", - "3'": "spanish", - "4'": "chinese_simplified", - "5'": "chinese_traditional", - "6'": "french", - "7'": "italian", - "8'": "czech", - "9'": "portuguese", # not in BIP-85 but in BIP-39 test vectors +RANGES = { + name: app.number_range for name, app in APPS.items() if app.number_range is not None } -assert set(INDEX_TO_LANGUAGE.values()) == set(LANGUAGES.keys()) +CODE_TO_APP = {app.code: app for app in APPS.values()} def apply_85(derived_key: ExtendedKey, path: str) -> Dict[str, Union[bytes, str]]: @@ -64,81 +49,20 @@ def apply_85(derived_key: ExtendedKey, path: str) -> Dict[str, Union[bytes, str] raise ValueError( f"Paths should have 4+ segments, all hardened children: {path}" ) - app, *indexes = segments[2:] + app_code = segments[2] + app_segments = segments[3:] + + if app_code not in CODE_TO_APP: + raise NotImplementedError(f"Unsupported BIP-85 application {app_code}") + app = CODE_TO_APP[app_code] entropy = to_entropy(derived_key.data[1:]) + kwargs = app.parse_path(app_segments) - if app == APPLICATIONS["mnemonic"]: - language_index, n_words = indexes[:2] - n_words = int(n_words.rstrip("'")) - if n_words not in N_WORDS_META.keys(): - raise ValueError(f"Unsupported number of words: {n_words}.") - language = INDEX_TO_LANGUAGE[language_index] - n_bytes = N_WORDS_META[n_words]["entropy_bits"] // 8 - trimmed_entropy = entropy[:n_bytes] - words = entropy_to_words(n_words, trimmed_entropy, language) - assert validate_mnemonic_words(words, language) - - return { - "entropy": trimmed_entropy, - "application": " ".join(words), - } - elif app == APPLICATIONS["wif"]: - trimmed_entropy = entropy[: 256 // 8] - prefix = b"\x80" if derived_key.get_network() == "mainnet" else b"\xef" - suffix = b"\x01" # use with compressed public keys because BIP-32 - extended = prefix + trimmed_entropy + suffix - - return { - "entropy": trimmed_entropy, - "application": base58.b58encode_check(extended).decode("utf-8"), - } - elif app == APPLICATIONS["xprv"]: - derived_key = ExtendedKey( - version=VERSIONS["mainnet"]["private"], - depth=bytes(1), - finger=bytes(4), - child_number=bytes(4), - chain_code=entropy[:32], - data=bytes(1) + entropy[32:], - ) + if app.name == "wif": + kwargs["network"] = derived_key.get_network() - return { - "entropy": entropy[32:], - "application": str(derived_key), - } - elif app == APPLICATIONS["hex"]: - num_bytes = int(indexes[0].rstrip("'")) - if not (16 <= num_bytes <= 64): - raise ValueError(f"Expected num_bytes in [16, 64], got {num_bytes}") - - return {"entropy": entropy, "application": to_hex_string(entropy[:num_bytes])} - elif app == APPLICATIONS["base64"]: - pwd_len = int(indexes[0].rstrip("'")) - if not (20 <= pwd_len <= 86): - raise ValueError(f"Expected pwd_len in [20, 86], got {pwd_len}") - - return { - "entropy": entropy, - "application": base64.b64encode(entropy).decode("utf-8")[:pwd_len], - } - elif app == APPLICATIONS["base85"]: - pwd_len = int(indexes[0].rstrip("'")) - if not (10 <= pwd_len <= 80): - raise ValueError("Expected pwd_len in [10, 80], got {pwd_len}") - - return { - "entropy": entropy, - "application": base64.b85encode(entropy).decode("utf-8")[:pwd_len], - } - elif app == APPLICATIONS["dice"]: - sides, rolls, index = (int(s.rstrip("'")) for s in indexes[:3]) - return { - "entropy": entropy, - "application": do_rolls(entropy, sides, rolls, index), - } - else: - raise NotImplementedError(f"Unsupported BIP-85 application {app}") + return app.apply(entropy, **kwargs) def to_entropy(data: bytes) -> bytes: @@ -152,20 +76,6 @@ def derive(master: ExtendedKey, path: str, private: bool = True) -> ExtendedKey: return derive_key_bip32(master, split_and_validate(path), private) -class DRNG: - def __init__(self, seed: bytes): - if len(seed) != 64: - raise ValueError("Seed must be exactly 64 bytes long") - self.shake = hashlib.shake_256(seed) - self.cursor = 0 - - def read(self, n: int) -> bytes: - start = self.cursor - self.cursor = stop = start + n - - return self.shake.digest(stop)[start:stop] - - def split_and_validate(path: str): segments = path.split("/") if segments[0] != "m": diff --git a/src/bipsea/constants.py b/src/bipsea/constants.py new file mode 100644 index 00000000..402cbf79 --- /dev/null +++ b/src/bipsea/constants.py @@ -0,0 +1,3 @@ +PURPOSE_CODES = {"BIP-85": "83696968'"} + +HMAC_KEY = b"bip-entropy-from-k" diff --git a/src/bipsea/drng.py b/src/bipsea/drng.py new file mode 100644 index 00000000..3b7bb686 --- /dev/null +++ b/src/bipsea/drng.py @@ -0,0 +1,14 @@ +import hashlib + + +class DRNG: + def __init__(self, seed: bytes): + if len(seed) != 64: + raise ValueError("Seed must be exactly 64 bytes long") + self.shake = hashlib.shake_256(seed) + self.cursor = 0 + + def read(self, n: int) -> bytes: + start = self.cursor + self.cursor = stop = start + n + return self.shake.digest(stop)[start:stop] diff --git a/src/bipsea/protocol.py b/src/bipsea/protocol.py new file mode 100644 index 00000000..e7c8c316 --- /dev/null +++ b/src/bipsea/protocol.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Protocol, runtime_checkable + + +@dataclass(frozen=True) +class Param: + name: str + flag: str + type: type + required: bool = False + default: Any = None + range: Optional[tuple[int, int]] = None + choices: Optional[list[str]] = None + help: str = "" + + +@dataclass(frozen=True) +class TestVector: + master: str + path: str + entropy: str + output: str + + +@runtime_checkable +class BIP85App(Protocol): + name: str + code: str + + @property + def number_range(self) -> Optional[tuple[int, int]]: ... + + @property + def params(self) -> list[Param]: ... + + def path_segments(self, index: int, **kwargs) -> list[str]: ... + + def parse_path(self, segments: list[str]) -> dict[str, Any]: ... + + def apply(self, entropy: bytes, **kwargs) -> dict[str, Any]: ... + + @property + def vectors(self) -> list[TestVector]: ... diff --git a/src/bipsea/registry.py b/src/bipsea/registry.py new file mode 100644 index 00000000..6109f075 --- /dev/null +++ b/src/bipsea/registry.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Dict + +from .apps import APPS +from .protocol import BIP85App + + +def get_apps() -> Dict[str, BIP85App]: + return APPS + + +def get_app(name: str) -> BIP85App: + if name not in APPS: + raise ValueError(f"Unknown app: {name}") + return APPS[name] diff --git a/tests/test_bip85.py b/tests/test_bip85.py index 0412e455..fece2ad4 100644 --- a/tests/test_bip85.py +++ b/tests/test_bip85.py @@ -5,17 +5,7 @@ import base58 import pytest from Crypto.PublicKey import RSA -from data.bip85_vectors import ( - BIP_39, - COMMON_XPRV, - DICE, - EXT_KEY_TO_ENTROPY, - HEX, - PWD_BASE64, - PWD_BASE85, - WIF, - XPRV, -) +from data.bip85_vectors import COMMON_XPRV, EXT_KEY_TO_ENTROPY, PWD_BASE64 from bipsea.bip32types import parse_ext_key from bipsea.bip39 import LANGUAGES, validate_mnemonic_words @@ -28,6 +18,7 @@ split_and_validate, to_entropy, ) +from bipsea.registry import APPS from bipsea.util import LOGGER_NAME, to_hex_string logger = logging.getLogger(LOGGER_NAME) @@ -72,41 +63,26 @@ def test_pwd_base64_entropy(vector): assert vector["derived_entropy"] == to_hex_string(output["entropy"]) -@pytest.mark.parametrize("vector", PWD_BASE85) -def test_pwd_base85(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - assert vector["derived_pwd"] == output["application"] - assert vector["derived_entropy"] == to_hex_string(output["entropy"]) +def _all_app_vectors(): + for app in APPS.values(): + for v in app.vectors: + yield pytest.param(app, v, id=f"{app.name}-{v.path}") -@pytest.mark.parametrize( - "vector", - BIP_39, - ids=[f"BIP_39-{v['mnemonic_length']}" for v in BIP_39], -) -def test_mnemonic(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] +@pytest.mark.parametrize("app,vector", list(_all_app_vectors())) +def test_app_vectors(app, vector): + master = parse_ext_key(vector.master) + path = vector.path output = apply_85(derive(master, path), path) - assert to_hex_string(output["entropy"]) == vector["derived_entropy"] - words = output["application"].split(" ") - assert len(words) == vector["mnemonic_length"] - assert output["application"] == vector["derived_mnemonic"] - assert validate_mnemonic_words(words, "english") + assert to_hex_string(output["entropy"]) == vector.entropy + assert output["application"] == vector.output @pytest.mark.filterwarnings("ignore:.*184 bits") @pytest.mark.parametrize("lang", LANGUAGES, ids=[lang for lang in LANGUAGES]) -@pytest.mark.parametrize( - "vector", - BIP_39, - ids=[f"BIP_39-{v['mnemonic_length']}" for v in BIP_39], -) -def test_mnemonic_languages(vector, lang): - n_words = vector["mnemonic_length"] - master = parse_ext_key(vector["master"]) +@pytest.mark.parametrize("n_words", (12, 18, 24)) +def test_mnemonic_languages(n_words, lang): + master = parse_ext_key(COMMON_XPRV) code = next(k for k, v in INDEX_TO_LANGUAGE.items() if v == lang) path = f"m/83696968'/39'/{code}/{n_words}'" output = apply_85(derive(master, path), path) @@ -114,14 +90,6 @@ def test_mnemonic_languages(vector, lang): assert validate_mnemonic_words(words, lang) -@pytest.mark.parametrize("vector", HEX) -def test_hex(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - assert vector["derived_entropy"] == output["application"] - - @pytest.mark.slow @pytest.mark.parametrize("key_bits", [1024]) def test_rsa(key_bits): @@ -141,24 +109,6 @@ def test_rsa(key_bits): assert data[0] != data[1] -@pytest.mark.parametrize("vector", WIF) -def test_wif(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - assert to_hex_string(output["entropy"]) == vector["derived_entropy"] - assert output["application"] == vector["derived_wif"] - - -@pytest.mark.parametrize("vector", XPRV) -def test_xprv(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - assert vector["derived_key"] == output["application"] - assert to_hex_string(output["entropy"]) == vector["derived_entropy"] - - def test_private_key_to_wif(): """follow the procedure from https://en.bitcoin.it/wiki/Wallet_import_format""" @@ -175,19 +125,6 @@ def test_private_key_to_wif(): assert wif == base58.b58encode_check(extended) -@pytest.mark.parametrize("vector", DICE) -def test_dice(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - rolls = output["application"] - assert rolls == vector["derived_rolls"] - rolls_int = [int(r) for r in rolls.split(",")] - assert len(rolls_int) == 10 - assert all(0 <= r < 10 for r in rolls_int) - assert to_hex_string(output["entropy"]) == vector["derived_entropy"] - - @pytest.mark.parametrize( "path, works", [ diff --git a/tests/test_cli.py b/tests/test_cli.py index f71d4376..5ef5dfc6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,16 +9,14 @@ import pytest from click.testing import CliRunner from data.bip39_vectors import VECTORS -from data.bip85_vectors import ( - BIP_39, - COMMON_XPRV, - DICE, - HEX, - PWD_BASE64, - PWD_BASE85, - WIF, -) - +from data.bip85_vectors import COMMON_XPRV + +from bipsea.apps.base64.app import app as base64_app +from bipsea.apps.base85.app import app as base85_app +from bipsea.apps.dice.app import app as dice_app +from bipsea.apps.hex.app import app as hex_app +from bipsea.apps.mnemonic.app import app as mnemonic_app +from bipsea.apps.wif.app import app as wif_app from bipsea.bip32types import validate_prv_str from bipsea.bip39 import LANGUAGES, validate_mnemonic_words from bipsea.bipsea import ISO_TO_LANGUAGE, N_WORDS_ALLOWED, cli, try_for_pipe_input @@ -218,18 +216,19 @@ def test_bad_n(self, runner, app, n): @pytest.mark.parametrize( "vector", - BIP_39, - ids=[f"BIP_39-{v['mnemonic_length']}-words" for v in BIP_39], + mnemonic_app.vectors, + ids=[f"mnemonic-{v.path}" for v in mnemonic_app.vectors], ) def test_mnemonic(self, runner, vector): - xprv = vector["master"] - n_words = vector["mnemonic_length"] + xprv = vector.master + segments = vector.path.split("/") + n_words = int(segments[4].rstrip("'")) result = runner.invoke( cli, ["derive", "-a", "mnemonic", "--xprv", xprv, "-n", n_words] ) assert result.exit_code == 0 words = result.output.strip() - assert words == vector["derived_mnemonic"] + assert words == vector.output @pytest.mark.parametrize("iso", [v["code"] for v in LANGUAGES.values()]) def test_mnemonic_languages(self, runner, iso): @@ -241,42 +240,55 @@ def test_mnemonic_languages(self, runner, iso): words = result.output.strip() assert validate_mnemonic_words(words.split(" "), ISO_TO_LANGUAGE[iso]) - @pytest.mark.parametrize("vector", DICE) + @pytest.mark.parametrize("vector", dice_app.vectors) def test_dice(self, runner, vector): - xprv = vector["master"] + xprv = vector.master + segments = vector.path.split("/") + sides = int(segments[3].rstrip("'")) + rolls = int(segments[4].rstrip("'")) result = runner.invoke( - cli, ["derive", "-a", "dice", "-x", xprv, "-n", 10, "-s", 6] + cli, ["derive", "-a", "dice", "-x", xprv, "-n", rolls, "-s", sides] ) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_rolls"] + assert result.output.strip() == vector.output - @pytest.mark.parametrize("vector", HEX) + @pytest.mark.parametrize("vector", hex_app.vectors) def test_hex(self, runner, vector): - xprv = vector["master"] - result = runner.invoke(cli, ["derive", "-a", "hex", "-x", xprv, "-n", 64]) + xprv = vector.master + segments = vector.path.split("/") + n_bytes = int(segments[3].rstrip("'")) + result = runner.invoke(cli, ["derive", "-a", "hex", "-x", xprv, "-n", n_bytes]) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_entropy"] + assert result.output.strip() == vector.output - @pytest.mark.parametrize("vector", WIF) + @pytest.mark.parametrize("vector", wif_app.vectors) def test_wif(self, runner, vector): - xprv = vector["master"] + xprv = vector.master result = runner.invoke(cli, ["derive", "-a", "wif", "-x", xprv]) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_wif"] + assert result.output.strip() == vector.output - @pytest.mark.parametrize("vector", PWD_BASE64) + @pytest.mark.parametrize("vector", base64_app.vectors) def test_base64(self, runner, vector): - xprv = vector["master"] - result = runner.invoke(cli, ["derive", "-a", "base64", "-x", xprv, "-n", 21]) + xprv = vector.master + segments = vector.path.split("/") + length = int(segments[3].rstrip("'")) + result = runner.invoke( + cli, ["derive", "-a", "base64", "-x", xprv, "-n", length] + ) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_pwd"] + assert result.output.strip() == vector.output - @pytest.mark.parametrize("vector", PWD_BASE85) + @pytest.mark.parametrize("vector", base85_app.vectors) def test_base85(self, runner, vector): - xprv = vector["master"] - result = runner.invoke(cli, ["derive", "-a", "base85", "-x", xprv, "-n", 12]) + xprv = vector.master + segments = vector.path.split("/") + length = int(segments[3].rstrip("'")) + result = runner.invoke( + cli, ["derive", "-a", "base85", "-x", xprv, "-n", length] + ) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_pwd"] + assert result.output.strip() == vector.output @pytest.mark.parametrize("app", ("wif", "xprv")) def test_num_not_allowed(self, runner, app): From 20385be6045dab4449ed265c78020d118af54382 Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sun, 28 Dec 2025 19:50:04 -0700 Subject: [PATCH 02/10] add Param for better encapsulation --- src/bipsea/apps/base64/app.py | 3 ++- src/bipsea/apps/base85/app.py | 3 ++- src/bipsea/apps/dice/app.py | 5 +++-- src/bipsea/apps/hex/app.py | 3 ++- src/bipsea/apps/mnemonic/app.py | 5 +++-- src/bipsea/protocol.py | 24 +++++++++++++++++++++--- 6 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/bipsea/apps/base64/app.py b/src/bipsea/apps/base64/app.py index 77f36252..73cb058f 100644 --- a/src/bipsea/apps/base64/app.py +++ b/src/bipsea/apps/base64/app.py @@ -18,11 +18,12 @@ def params(self) -> list[Param]: return [ Param( "length", - "-n", + ("-n", "--length"), int, required=True, range=(20, 86), help="Password length in characters", + role="number", ), ] diff --git a/src/bipsea/apps/base85/app.py b/src/bipsea/apps/base85/app.py index 62939103..7699330c 100644 --- a/src/bipsea/apps/base85/app.py +++ b/src/bipsea/apps/base85/app.py @@ -18,11 +18,12 @@ def params(self) -> list[Param]: return [ Param( "length", - "-n", + ("-n", "--length"), int, required=True, range=(10, 80), help="Password length in characters", + role="number", ), ] diff --git a/src/bipsea/apps/dice/app.py b/src/bipsea/apps/dice/app.py index 00f8eff3..e26dccdf 100644 --- a/src/bipsea/apps/dice/app.py +++ b/src/bipsea/apps/dice/app.py @@ -19,15 +19,16 @@ def params(self) -> list[Param]: return [ Param( "rolls", - "-n", + ("-n", "--rolls"), int, required=True, range=(1, 10_000), help="Number of rolls", + role="number", ), Param( "sides", - "-s", + ("-s", "--sides"), int, default=6, range=(2, None), diff --git a/src/bipsea/apps/hex/app.py b/src/bipsea/apps/hex/app.py index fd7a4c48..72697c5f 100644 --- a/src/bipsea/apps/hex/app.py +++ b/src/bipsea/apps/hex/app.py @@ -18,11 +18,12 @@ def params(self) -> list[Param]: return [ Param( "length", - "-n", + ("-n", "--length"), int, required=True, range=(16, 64), help="Output length in bytes", + role="number", ), ] diff --git a/src/bipsea/apps/mnemonic/app.py b/src/bipsea/apps/mnemonic/app.py index 90e1ef46..6a9d3a99 100644 --- a/src/bipsea/apps/mnemonic/app.py +++ b/src/bipsea/apps/mnemonic/app.py @@ -40,15 +40,16 @@ def params(self) -> list[Param]: return [ Param( "n_words", - "-n", + ("-n", "--words"), int, required=True, choices=[str(n) for n in N_WORDS_META.keys()], help="Number of mnemonic words", + role="number", ), Param( "language", - "-t", + ("-t", "--language"), str, default="english", choices=list(LANGUAGES.keys()), diff --git a/src/bipsea/protocol.py b/src/bipsea/protocol.py index e7c8c316..68638852 100644 --- a/src/bipsea/protocol.py +++ b/src/bipsea/protocol.py @@ -1,19 +1,37 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Optional, Protocol, runtime_checkable +from typing import Any, Optional, Protocol, Tuple, runtime_checkable + +import click @dataclass(frozen=True) class Param: name: str - flag: str + flags: tuple[str, ...] type: type required: bool = False default: Any = None - range: Optional[tuple[int, int]] = None + range: Optional[Tuple[Optional[int], Optional[int]]] = None choices: Optional[list[str]] = None help: str = "" + role: Optional[str] = None + + def click_option_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = {"help": self.help} + if self.required: + kwargs["required"] = True + if self.default is not None: + kwargs["default"] = self.default + if self.choices is not None: + kwargs["type"] = click.Choice(self.choices) + elif self.range is not None and self.type is int: + min_, max_ = self.range + kwargs["type"] = click.IntRange(min_, max_) + else: + kwargs["type"] = self.type + return kwargs @dataclass(frozen=True) From 5d0108ea914200d2b097198b9850fc37dffe3447 Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sun, 28 Dec 2025 19:50:24 -0700 Subject: [PATCH 03/10] add drng app --- src/bipsea/apps/drng/app.py | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/bipsea/apps/drng/app.py diff --git a/src/bipsea/apps/drng/app.py b/src/bipsea/apps/drng/app.py new file mode 100644 index 00000000..e584a45b --- /dev/null +++ b/src/bipsea/apps/drng/app.py @@ -0,0 +1,49 @@ +from typing import Any + +from bipsea.drng import DRNG +from bipsea.protocol import Param, Vector +from bipsea.util import to_hex_string + + +class DrngApp: + name = "drng" + code = "0'" + + @property + def params(self) -> list[Param]: + return [ + Param( + "length", + "-n", + int, + required=True, + help="Number of bytes to generate", + ), + ] + + def path_segments(self, index: int, **_) -> list[str]: + return ["0'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + drng = DRNG(entropy) + return { + "entropy": entropy, + "application": to_hex_string(drng.read(length)), + } + + @property + def vectors(self) -> list[Vector]: + return [ + Vector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/0'/0'/0'", + entropy="efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7", + output="b78b1ee6b345eae6836c2d53d33c64cdaf9a696487be81b03e822dc84b3f1cd883d7559e53d175f243e4c349e822a957bbff9224bc5dde9492ef54e8a439f6bc8c7355b87a925a37ee405a7502991111", + ), + ] + + +app = DrngApp() From 02fe0156e8cc2e8749345eabc0905a25cb46bbdf Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sun, 28 Dec 2025 19:54:02 -0700 Subject: [PATCH 04/10] refactor protocol name --- src/bipsea/{protocol.py => app_protocol.py} | 0 src/bipsea/apps/__init__.py | 2 +- src/bipsea/apps/base64/app.py | 2 +- src/bipsea/apps/base85/app.py | 2 +- src/bipsea/apps/dice/app.py | 2 +- src/bipsea/apps/drng/app.py | 9 +++++---- src/bipsea/apps/hex/app.py | 2 +- src/bipsea/apps/mnemonic/app.py | 2 +- src/bipsea/apps/wif/app.py | 2 +- src/bipsea/apps/xprv/app.py | 2 +- src/bipsea/registry.py | 2 +- 11 files changed, 14 insertions(+), 13 deletions(-) rename src/bipsea/{protocol.py => app_protocol.py} (100%) diff --git a/src/bipsea/protocol.py b/src/bipsea/app_protocol.py similarity index 100% rename from src/bipsea/protocol.py rename to src/bipsea/app_protocol.py diff --git a/src/bipsea/apps/__init__.py b/src/bipsea/apps/__init__.py index cc2dad1b..5ca04f59 100644 --- a/src/bipsea/apps/__init__.py +++ b/src/bipsea/apps/__init__.py @@ -2,6 +2,7 @@ from typing import Dict +from bipsea.app_protocol import BIP85App from bipsea.apps.base64.app import app as base64_app from bipsea.apps.base85.app import app as base85_app from bipsea.apps.dice.app import app as dice_app @@ -9,7 +10,6 @@ from bipsea.apps.mnemonic.app import app as mnemonic_app from bipsea.apps.wif.app import app as wif_app from bipsea.apps.xprv.app import app as xprv_app -from bipsea.protocol import BIP85App APPS: Dict[str, BIP85App] = { base64_app.name: base64_app, diff --git a/src/bipsea/apps/base64/app.py b/src/bipsea/apps/base64/app.py index 73cb058f..5e45274a 100644 --- a/src/bipsea/apps/base64/app.py +++ b/src/bipsea/apps/base64/app.py @@ -1,8 +1,8 @@ import base64 as b64 from typing import Any +from bipsea.app_protocol import Param, TestVector from bipsea.apps.shared import hardened_int -from bipsea.protocol import Param, TestVector class Base64App: diff --git a/src/bipsea/apps/base85/app.py b/src/bipsea/apps/base85/app.py index 7699330c..a924414d 100644 --- a/src/bipsea/apps/base85/app.py +++ b/src/bipsea/apps/base85/app.py @@ -1,8 +1,8 @@ import base64 as b64 from typing import Any +from bipsea.app_protocol import Param, TestVector from bipsea.apps.shared import hardened_int -from bipsea.protocol import Param, TestVector class Base85App: diff --git a/src/bipsea/apps/dice/app.py b/src/bipsea/apps/dice/app.py index e26dccdf..6f986d30 100644 --- a/src/bipsea/apps/dice/app.py +++ b/src/bipsea/apps/dice/app.py @@ -1,9 +1,9 @@ import math from typing import Any +from bipsea.app_protocol import Param, TestVector from bipsea.apps.shared import hardened_int from bipsea.drng import DRNG -from bipsea.protocol import Param, TestVector class DiceApp: diff --git a/src/bipsea/apps/drng/app.py b/src/bipsea/apps/drng/app.py index e584a45b..cd89eacc 100644 --- a/src/bipsea/apps/drng/app.py +++ b/src/bipsea/apps/drng/app.py @@ -1,7 +1,7 @@ from typing import Any +from bipsea.app_protocol import Param, TestVector from bipsea.drng import DRNG -from bipsea.protocol import Param, Vector from bipsea.util import to_hex_string @@ -14,10 +14,11 @@ def params(self) -> list[Param]: return [ Param( "length", - "-n", + ("-n", "--length"), int, required=True, help="Number of bytes to generate", + role="number", ), ] @@ -35,9 +36,9 @@ def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: } @property - def vectors(self) -> list[Vector]: + def vectors(self) -> list[TestVector]: return [ - Vector( + TestVector( master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", path="m/83696968'/0'/0'/0'", entropy="efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7", diff --git a/src/bipsea/apps/hex/app.py b/src/bipsea/apps/hex/app.py index 72697c5f..ab6ca694 100644 --- a/src/bipsea/apps/hex/app.py +++ b/src/bipsea/apps/hex/app.py @@ -1,7 +1,7 @@ from typing import Any +from bipsea.app_protocol import Param, TestVector from bipsea.apps.shared import hardened_int -from bipsea.protocol import Param, TestVector from bipsea.util import to_hex_string diff --git a/src/bipsea/apps/mnemonic/app.py b/src/bipsea/apps/mnemonic/app.py index 6a9d3a99..f5c29e96 100644 --- a/src/bipsea/apps/mnemonic/app.py +++ b/src/bipsea/apps/mnemonic/app.py @@ -1,5 +1,6 @@ from typing import Any +from bipsea.app_protocol import Param, TestVector from bipsea.apps.shared import hardened_int from bipsea.bip39 import ( LANGUAGES, @@ -7,7 +8,6 @@ entropy_to_words, validate_mnemonic_words, ) -from bipsea.protocol import Param, TestVector INDEX_TO_LANGUAGE = { "0'": "english", diff --git a/src/bipsea/apps/wif/app.py b/src/bipsea/apps/wif/app.py index ebfe84eb..be3935c0 100644 --- a/src/bipsea/apps/wif/app.py +++ b/src/bipsea/apps/wif/app.py @@ -2,7 +2,7 @@ import base58 -from bipsea.protocol import Param, TestVector +from bipsea.app_protocol import Param, TestVector class WifApp: diff --git a/src/bipsea/apps/xprv/app.py b/src/bipsea/apps/xprv/app.py index f3767656..79d616bf 100644 --- a/src/bipsea/apps/xprv/app.py +++ b/src/bipsea/apps/xprv/app.py @@ -1,7 +1,7 @@ from typing import Any +from bipsea.app_protocol import Param, TestVector from bipsea.bip32 import VERSIONS, ExtendedKey -from bipsea.protocol import Param, TestVector class XprvApp: diff --git a/src/bipsea/registry.py b/src/bipsea/registry.py index 6109f075..0f97b4b4 100644 --- a/src/bipsea/registry.py +++ b/src/bipsea/registry.py @@ -2,8 +2,8 @@ from typing import Dict +from .app_protocol import BIP85App from .apps import APPS -from .protocol import BIP85App def get_apps() -> Dict[str, BIP85App]: From df2e62d4c33132b4c113ff253b8453bd7e8b27a2 Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sat, 4 Apr 2026 12:13:17 -0700 Subject: [PATCH 05/10] bulk refactor --- Makefile | 6 +-- README.md | 2 + src/bipsea/app_protocol.py | 22 +------- src/bipsea/apps/README.md | 90 +++++++++++++++++++++++++++++++++ src/bipsea/apps/base64/app.py | 4 -- src/bipsea/apps/base85/app.py | 4 -- src/bipsea/apps/dice/app.py | 4 -- src/bipsea/apps/hex/app.py | 4 -- src/bipsea/apps/mnemonic/app.py | 4 -- src/bipsea/apps/wif/app.py | 4 -- src/bipsea/apps/xprv/app.py | 4 -- src/bipsea/bip85.py | 11 +++- src/bipsea/cli_adapter.py | 31 ++++++++++++ tests/test_cli.py | 37 ++++++++++++++ 14 files changed, 175 insertions(+), 52 deletions(-) create mode 100644 src/bipsea/apps/README.md create mode 100644 src/bipsea/cli_adapter.py diff --git a/Makefile b/Makefile index 52bba732..aac27e7f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean install test +.PHONY: all build check clean download-lists install install-ci install-dist install-go lint publish push test test-all test-dist test-integration all:: install build @@ -13,7 +13,7 @@ test-dist:: clean build install-dist test-integration test-integration:: poetry run pytest "tests/test_cli.py::TestIntegration" -m "" -n auto -push:: lint test-all git-off-main git-no-unsaved +push:: git-off-main git-no-unsaved lint test-all @branch=$$(git symbolic-ref --short HEAD); \ git push origin $$branch @@ -73,7 +73,7 @@ git-on-main:: fi git-no-unsaved:: - @if ! git diff --quiet; then \ + @if ! git diff --quiet || ! git diff --cached --quiet; then \ echo "There are unsaved changes in the git repository."; \ exit 1; \ fi diff --git a/README.md b/README.md index 24ebcc97..69fd8a34 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,8 @@ make test ``` See [Makefile](./Makefile) for more commands. +To add a new BIP-85 application, see the +[app developer guide](./src/bipsea/apps/README.md). ## Is the bipsea implementation correct? diff --git a/src/bipsea/app_protocol.py b/src/bipsea/app_protocol.py index 68638852..9cec883e 100644 --- a/src/bipsea/app_protocol.py +++ b/src/bipsea/app_protocol.py @@ -3,11 +3,11 @@ from dataclasses import dataclass from typing import Any, Optional, Protocol, Tuple, runtime_checkable -import click - @dataclass(frozen=True) class Param: + """App parameter definition. Pure data, no framework coupling.""" + name: str flags: tuple[str, ...] type: type @@ -18,21 +18,6 @@ class Param: help: str = "" role: Optional[str] = None - def click_option_kwargs(self) -> dict[str, Any]: - kwargs: dict[str, Any] = {"help": self.help} - if self.required: - kwargs["required"] = True - if self.default is not None: - kwargs["default"] = self.default - if self.choices is not None: - kwargs["type"] = click.Choice(self.choices) - elif self.range is not None and self.type is int: - min_, max_ = self.range - kwargs["type"] = click.IntRange(min_, max_) - else: - kwargs["type"] = self.type - return kwargs - @dataclass(frozen=True) class TestVector: @@ -47,9 +32,6 @@ class BIP85App(Protocol): name: str code: str - @property - def number_range(self) -> Optional[tuple[int, int]]: ... - @property def params(self) -> list[Param]: ... diff --git a/src/bipsea/apps/README.md b/src/bipsea/apps/README.md new file mode 100644 index 00000000..fa5fd12d --- /dev/null +++ b/src/bipsea/apps/README.md @@ -0,0 +1,90 @@ +# Adding a BIP-85 app + +Each app lives in its own directory under `src/bipsea/apps/` and implements the +`BIP85App` protocol defined in [`app_protocol.py`](../app_protocol.py). + + +## Protocol + +Implement [`BIP85App`](../app_protocol.py). `apply()` must return +`{"entropy": bytes, "application": str}`. + + +## Param + +Params declare the app's CLI options as pure data. The CLI adapter converts +them to Click options automatically. + +```python +Param( + name="length", # kwarg name passed to path_segments() and apply() + flags=("-n", "--length"), + type=int, + required=True, + range=(20, 86), # becomes click.IntRange + help="Password length in characters", + role="number", # "number" = the primary numeric param +) +``` + +- `choices` becomes `click.Choice` +- `range` becomes `click.IntRange` +- `role="number"` identifies the param used for output length (used by range validation) + + +## Steps + +1. Create `src/bipsea/apps/yourapp/app.py` +2. Implement the protocol (see `base64/app.py` for a minimal example) +3. Export a module-level `app` instance +4. Register in `src/bipsea/apps/__init__.py` +5. Add test vectors from the BIP-85 spec +6. Run `make test lint` + + +## Example + +```python +from typing import Any + +from bipsea.app_protocol import Param, TestVector + + +class MyApp: + name = "myapp" + code = "12345'" + + @property + def params(self) -> list[Param]: + return [ + Param("length", ("-n", "--length"), int, required=True, + range=(1, 64), help="Output length", role="number"), + ] + + def path_segments(self, index: int, length: int, **_) -> list[str]: + return [f"{length}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + from bipsea.apps.shared import hardened_int + return {"length": hardened_int(segments[0])} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + return { + "entropy": entropy, + "application": entropy.hex()[:length], + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv...", + path="m/83696968'/12345'/32'/0'", + entropy="abcd...", + output="abcd...", + ), + ] + + +app = MyApp() +``` diff --git a/src/bipsea/apps/base64/app.py b/src/bipsea/apps/base64/app.py index 5e45274a..47939cc3 100644 --- a/src/bipsea/apps/base64/app.py +++ b/src/bipsea/apps/base64/app.py @@ -9,10 +9,6 @@ class Base64App: name = "base64" code = "707764'" - @property - def number_range(self) -> tuple[int, int]: - return (20, 86) - @property def params(self) -> list[Param]: return [ diff --git a/src/bipsea/apps/base85/app.py b/src/bipsea/apps/base85/app.py index a924414d..50d36d08 100644 --- a/src/bipsea/apps/base85/app.py +++ b/src/bipsea/apps/base85/app.py @@ -9,10 +9,6 @@ class Base85App: name = "base85" code = "707785'" - @property - def number_range(self) -> tuple[int, int]: - return (10, 80) - @property def params(self) -> list[Param]: return [ diff --git a/src/bipsea/apps/dice/app.py b/src/bipsea/apps/dice/app.py index 6f986d30..932fffb0 100644 --- a/src/bipsea/apps/dice/app.py +++ b/src/bipsea/apps/dice/app.py @@ -10,10 +10,6 @@ class DiceApp: name = "dice" code = "89101'" - @property - def number_range(self) -> tuple[int, int]: - return (1, 10_000) - @property def params(self) -> list[Param]: return [ diff --git a/src/bipsea/apps/hex/app.py b/src/bipsea/apps/hex/app.py index ab6ca694..eaa30e10 100644 --- a/src/bipsea/apps/hex/app.py +++ b/src/bipsea/apps/hex/app.py @@ -9,10 +9,6 @@ class HexApp: name = "hex" code = "128169'" - @property - def number_range(self) -> tuple[int, int]: - return (16, 64) - @property def params(self) -> list[Param]: return [ diff --git a/src/bipsea/apps/mnemonic/app.py b/src/bipsea/apps/mnemonic/app.py index f5c29e96..2237ffa8 100644 --- a/src/bipsea/apps/mnemonic/app.py +++ b/src/bipsea/apps/mnemonic/app.py @@ -31,10 +31,6 @@ class MnemonicApp: name = "mnemonic" code = "39'" - @property - def number_range(self) -> None: - return None - @property def params(self) -> list[Param]: return [ diff --git a/src/bipsea/apps/wif/app.py b/src/bipsea/apps/wif/app.py index be3935c0..ff52c033 100644 --- a/src/bipsea/apps/wif/app.py +++ b/src/bipsea/apps/wif/app.py @@ -9,10 +9,6 @@ class WifApp: name = "wif" code = "2'" - @property - def number_range(self) -> None: - return None - @property def params(self) -> list[Param]: return [] diff --git a/src/bipsea/apps/xprv/app.py b/src/bipsea/apps/xprv/app.py index 79d616bf..0ec26659 100644 --- a/src/bipsea/apps/xprv/app.py +++ b/src/bipsea/apps/xprv/app.py @@ -8,10 +8,6 @@ class XprvApp: name = "xprv" code = "32'" - @property - def number_range(self) -> None: - return None - @property def params(self) -> list[Param]: return [] diff --git a/src/bipsea/bip85.py b/src/bipsea/bip85.py index 7973cd2d..1f2aa8cd 100644 --- a/src/bipsea/bip85.py +++ b/src/bipsea/bip85.py @@ -32,8 +32,17 @@ APPLICATIONS = {app.name: app.code for app in APPS.values()} APPLICATIONS["drng"] = "0'" + +def _number_range(app): + """Extract range from the param with role='number', if any.""" + for p in app.params: + if p.role == "number" and p.range is not None: + return p.range + return None + + RANGES = { - name: app.number_range for name, app in APPS.items() if app.number_range is not None + name: rng for name, app in APPS.items() if (rng := _number_range(app)) } CODE_TO_APP = {app.code: app for app in APPS.values()} diff --git a/src/bipsea/cli_adapter.py b/src/bipsea/cli_adapter.py new file mode 100644 index 00000000..62efac02 --- /dev/null +++ b/src/bipsea/cli_adapter.py @@ -0,0 +1,31 @@ +"""Adapter functions to convert protocol types to Click constructs.""" + +from typing import Any + +import click + +from .app_protocol import Param + + +def param_to_click_option(param: Param) -> tuple[tuple[str, ...], dict[str, Any]]: + """Convert a Param to click.option arguments. + + Returns: + (flags, kwargs) tuple for use with click.option(*flags, **kwargs) + """ + kwargs: dict[str, Any] = {"help": param.help} + + if param.required: + kwargs["required"] = True + if param.default is not None: + kwargs["default"] = param.default + + # Type resolution: choices > range > raw type + if param.choices is not None: + kwargs["type"] = click.Choice(param.choices) + elif param.range is not None: + kwargs["type"] = click.IntRange(*param.range) + else: + kwargs["type"] = param.type + + return param.flags, kwargs diff --git a/tests/test_cli.py b/tests/test_cli.py index 5ef5dfc6..e6767ffc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,10 +7,12 @@ from pathlib import Path import pytest +from click import Choice, IntRange from click.testing import CliRunner from data.bip39_vectors import VECTORS from data.bip85_vectors import COMMON_XPRV +from bipsea.app_protocol import Param from bipsea.apps.base64.app import app as base64_app from bipsea.apps.base85.app import app as base85_app from bipsea.apps.dice.app import app as dice_app @@ -20,6 +22,7 @@ from bipsea.bip32types import validate_prv_str from bipsea.bip39 import LANGUAGES, validate_mnemonic_words from bipsea.bipsea import ISO_TO_LANGUAGE, N_WORDS_ALLOWED, cli, try_for_pipe_input +from bipsea.cli_adapter import param_to_click_option from bipsea.util import ASCII_INPUTS, LOGGER_NAME logger = logging.getLogger(LOGGER_NAME) @@ -444,3 +447,37 @@ def test_commands(self, group, commands): ) finally: Path(script.name).unlink() + + +class TestCliAdapter: + def test_required_param(self): + param = Param("length", ("-n", "--length"), int, required=True, help="Length") + flags, kwargs = param_to_click_option(param) + assert flags == ("-n", "--length") + assert kwargs == {"help": "Length", "required": True, "type": int} + + def test_param_with_default(self): + param = Param("sides", ("-s", "--sides"), int, default=6, help="Die sides") + flags, kwargs = param_to_click_option(param) + assert kwargs["default"] == 6 + assert "required" not in kwargs + + def test_param_with_range(self): + param = Param("bytes", ("-b",), int, range=(16, 64), help="Bytes") + _, kwargs = param_to_click_option(param) + assert isinstance(kwargs["type"], IntRange) + assert kwargs["type"].min == 16 + assert kwargs["type"].max == 64 + + def test_param_with_choices(self): + param = Param("fmt", ("-f",), str, choices=["json", "csv"], help="Format") + _, kwargs = param_to_click_option(param) + assert isinstance(kwargs["type"], Choice) + assert list(kwargs["type"].choices) == ["json", "csv"] + + def test_real_app_params(self): + for app in (base64_app, dice_app, mnemonic_app): + for param in app.params: + flags, kwargs = param_to_click_option(param) + assert flags == param.flags + assert "type" in kwargs From f043ebe73f122b72f06c8dedccb8f02f661dc61e Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sat, 4 Apr 2026 12:18:45 -0700 Subject: [PATCH 06/10] add version sync guard and makefile workflow checks Add a dedicated version-sync check target and wire it into check/publish so mismatched CLI and package versions fail early. Also document test-dist environment behavior and keep derived range mapping formatted consistently. Made-with: Cursor --- Makefile | 10 +++++++--- src/bipsea/bip85.py | 4 +--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index aac27e7f..a52d3a32 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build check clean download-lists install install-ci install-dist install-go lint publish push test test-all test-dist test-integration +.PHONY: all build check check-version-sync clean download-lists install install-ci install-dist install-go lint publish push test test-all test-dist test-integration all:: install build @@ -9,6 +9,7 @@ test-all:: poetry run pytest -n auto -m "" test-dist:: clean build install-dist test-integration + # note: restores runtime-only deps; run `make install-ci` for dev deps again test-integration:: poetry run pytest "tests/test_cli.py::TestIntegration" -m "" -n auto @@ -28,7 +29,7 @@ clean:: rm -rf build dist *.egg-info .pytest_cache pip uninstall -y bipsea -publish:: download-lists git-no-unsaved git-on-main test-dist install-ci test +publish:: download-lists git-no-unsaved git-on-main check-version-sync test-dist install-ci test poetry publish install:: install-ci install-go @@ -44,13 +45,16 @@ install-go:: install-dist:: poetry install --without dev -check:: +check:: check-version-sync poetry run black . --check poetry run isort . --check poetry run flake8 . --ignore=E501,W503,E704 bash -n scripts/*.sh bash -n tests/*.sh +check-version-sync:: + @poetry run python -c "import pathlib,re,sys; p=pathlib.Path('pyproject.toml').read_text(); u=pathlib.Path('src/bipsea/util.py').read_text(); pv=re.search(r'(?m)^version\\s*=\\s*\"([^\"]+)\"', p).group(1); uv=re.search(r'(?m)^__version__\\s*=\\s*\"([^\"]+)\"', u).group(1); sys.exit(0) if pv==uv else sys.exit(f'Version mismatch: pyproject.toml={pv} util.py={uv}')" + lint:: isort . black . diff --git a/src/bipsea/bip85.py b/src/bipsea/bip85.py index 1f2aa8cd..aef73f6b 100644 --- a/src/bipsea/bip85.py +++ b/src/bipsea/bip85.py @@ -41,9 +41,7 @@ def _number_range(app): return None -RANGES = { - name: rng for name, app in APPS.items() if (rng := _number_range(app)) -} +RANGES = {name: rng for name, app in APPS.items() if (rng := _number_range(app))} CODE_TO_APP = {app.code: app for app in APPS.values()} From 9b3d6bc7a32b0c435ad5cff85ef48deb19dffab5 Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sat, 4 Apr 2026 12:18:56 -0700 Subject: [PATCH 07/10] bump project and cli version to 3.1.0 Align Poetry package metadata and CLI-reported version to 3.1.0 for the protocol-focused minor release. Made-with: Cursor --- pyproject.toml | 2 +- src/bipsea/util.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1104ff9d..a0f504d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "bipsea" -version = "3.0.0" +version = "3.1.0" description = "Composable Python CLI for Bitcoin mnemonics and BIP-85 secrets." readme = "README.md" authors = ["Aneesh Karve "] diff --git a/src/bipsea/util.py b/src/bipsea/util.py index 337111c6..fc975ac8 100644 --- a/src/bipsea/util.py +++ b/src/bipsea/util.py @@ -13,7 +13,7 @@ MIN_REL_ENTROPY = 0.50 # somewhat magic heuristic __app_name__ = "bipsea" -__version__ = "2.0.1" +__version__ = "3.1.0" LOGGER_NAME = __app_name__ From 696a118ab0b5288709f4f7ebb10c92959662234d Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sat, 4 Apr 2026 12:19:16 -0700 Subject: [PATCH 08/10] comment --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a52d3a32..84e7afc5 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ test-all:: poetry run pytest -n auto -m "" test-dist:: clean build install-dist test-integration - # note: restores runtime-only deps; run `make install-ci` for dev deps again + # after run `make install-ci` to restore dev deps test-integration:: poetry run pytest "tests/test_cli.py::TestIntegration" -m "" -n auto From 6ce4cf71ab6b3429908f0925a405aeb370290510 Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sat, 4 Apr 2026 12:35:36 -0700 Subject: [PATCH 09/10] drop python 3.8 support and bump to 3.2.0 Raise the minimum supported Python version to 3.9 across packaging, CI, and imports, and align CLI/package versions at 3.2.0. Made-with: Cursor --- .github/workflows/ci.yaml | 4 ++-- pyproject.toml | 6 +++--- src/bipsea/bip39.py | 7 +------ src/bipsea/util.py | 2 +- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 508acc63..b1828aa5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: API & CLI Py 3.8 - 3.12 (coverage) +name: API & CLI Py 3.9 - 3.12 (coverage) on: [pull_request] @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - name: checkout repo uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index a0f504d9..42df5cc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "bipsea" -version = "3.1.0" +version = "3.2.0" description = "Composable Python CLI for Bitcoin mnemonics and BIP-85 secrets." readme = "README.md" authors = ["Aneesh Karve "] @@ -27,7 +27,7 @@ classifiers = [ ] [tool.poetry.dependencies] -python = ">=3.8.1, <4.0" +python = ">=3.9, <4.0" click = "~8.1.3" base58 = "~2.1.1" build = "~1.2.1" @@ -37,7 +37,7 @@ ecdsa = "~0.19.0" black = "~24.4.2" flake8 = "~7.0.0" isort = "~5.13.2" -importlib-resources = { version = "^6.4.0", python = "<3.9" } +importlib-resources = "^6.4.0" pycryptodome = "~3.20.0" pytest = "~8.2.1" pytest-xdist = "~3.6.1" diff --git a/src/bipsea/bip39.py b/src/bipsea/bip39.py index e031b9be..f6830341 100644 --- a/src/bipsea/bip39.py +++ b/src/bipsea/bip39.py @@ -5,12 +5,7 @@ import secrets import warnings from hashlib import pbkdf2_hmac - -try: # pragma: no cover - from importlib.resources import files -except ImportError: # pragma: no cover - from importlib_resources import files # for Python 3.8 - +from importlib.resources import files from typing import List from unicodedata import normalize diff --git a/src/bipsea/util.py b/src/bipsea/util.py index fc975ac8..d682299e 100644 --- a/src/bipsea/util.py +++ b/src/bipsea/util.py @@ -13,7 +13,7 @@ MIN_REL_ENTROPY = 0.50 # somewhat magic heuristic __app_name__ = "bipsea" -__version__ = "3.1.0" +__version__ = "3.2.0" LOGGER_NAME = __app_name__ From 62b9dc135be7bf441caa69103d3fe9dd1eda5c3b Mon Sep 17 00:00:00 2001 From: Aneesh Karve Date: Sat, 4 Apr 2026 12:40:50 -0700 Subject: [PATCH 10/10] drop python 3.9 support (poetry 2.x requires 3.10+) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 4 ++-- poetry.lock | 7 ++----- pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b1828aa5..caa3201d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: API & CLI Py 3.9 - 3.12 (coverage) +name: API & CLI Py 3.10 - 3.12 (coverage) on: [pull_request] @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - name: checkout repo uses: actions/checkout@v4 diff --git a/poetry.lock b/poetry.lock index c7bf27f8..015a59d8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -293,9 +293,6 @@ files = [ {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, ] -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] @@ -592,5 +589,5 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" -python-versions = ">=3.8.1, <4.0" -content-hash = "efca24e2ccc4d92a447126ce480230beac9d854b32a04d908cc51fcb42aec3c6" +python-versions = ">=3.10, <4.0" +content-hash = "b66d659eca692165648808c2c19fcb238b8eb034f2b24d1c682cc4820bea3672" diff --git a/pyproject.toml b/pyproject.toml index 42df5cc4..cf979fd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] [tool.poetry.dependencies] -python = ">=3.9, <4.0" +python = ">=3.10, <4.0" click = "~8.1.3" base58 = "~2.1.1" build = "~1.2.1"