diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 673ffdd6..049c669b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,7 @@ jobs: maturin-version: ${{ env.maturin_version }} - name: Install built wheel run: | - pip install pytest pytest-asyncio based58 pybip39 typing-extensions jsonalias + pip install pytest pytest-asyncio based58 pybip39 mnemonic typing-extensions jsonalias pip install ${{ env.name }} --no-index --no-dependencies --find-links dist --force-reinstall pytest @@ -61,7 +61,7 @@ jobs: maturin-version: ${{ env.maturin_version }} - name: Install built wheel run: | - pip install pytest pytest-asyncio based58 pybip39 typing-extensions jsonalias + pip install pytest pytest-asyncio based58 pybip39 mnemonic typing-extensions jsonalias pip install ${{ env.name }} --no-index --no-dependencies --find-links dist --force-reinstall pytest - name: Upload wheels @@ -92,7 +92,7 @@ jobs: maturin-version: ${{ env.maturin_version }} - name: Install built wheel run: | - python -m pip install pytest pytest-asyncio based58 pybip39 typing-extensions jsonalias + python -m pip install pytest pytest-asyncio based58 pybip39 mnemonic typing-extensions jsonalias python -m pip install ${{ env.name }} --no-index --no-dependencies --find-links dist --force-reinstall python -m pytest --ignore=tests/bankrun - name: Upload wheels @@ -125,7 +125,7 @@ jobs: - name: Install built wheel if: matrix.target == 'x86_64' run: | - pip install pytest pytest-asyncio based58 pybip39 typing-extensions jsonalias + pip install pytest pytest-asyncio based58 pybip39 mnemonic typing-extensions jsonalias pip install ${{ env.name }} --no-index --no-dependencies --find-links dist --force-reinstall pytest - name: Upload wheels @@ -189,7 +189,7 @@ jobs: options: -v ${{ github.workspace }}:/io -w /io run: | apk add py3-pip - pip3 install -U pip pytest pytest-asyncio based58 pybip39 typing-extensions jsonalias --break-system-packages + pip3 install -U pip pytest pytest-asyncio based58 pybip39 mnemonic typing-extensions jsonalias --break-system-packages pip3 install ${{ env.name }} --find-links /io/dist/ --force-reinstall --no-index --no-dependencies --break-system-packages python3 -m pytest - name: Upload wheels diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bbadcb7..f07d9838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Add `Keypair.from_seed_and_derivation_path` [(#75)](https://github.com/kevinheavey/solders/pull/75) + ### Fixed - Fix (de)serialization of Account `owner` field [(#70)](https://github.com/kevinheavey/solders/pull/70) diff --git a/README.md b/README.md index 90a66783..46600f2a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ --- -[![Actions -Status](https://github.com/kevinheavey/solders/workflows/CI/badge.svg)](https://github.com/kevinheavey/solders/actions?query=workflow%3ACI) [![PyPI version](https://badge.fury.io/py/solders.svg)](https://badge.fury.io/py/solders) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/kevinheavey/solders/blob/main/LICENSE) diff --git a/crates/keypair/src/lib.rs b/crates/keypair/src/lib.rs index 53856fdb..e7dfa1db 100644 --- a/crates/keypair/src/lib.rs +++ b/crates/keypair/src/lib.rs @@ -1,11 +1,15 @@ use derive_more::{From, Into}; use pyo3::{prelude::*, types::PyBytes}; use serde::{Deserialize, Serialize}; -use solana_sdk::signer::{ - keypair::{ - keypair_from_seed, keypair_from_seed_phrase_and_passphrase, Keypair as KeypairOriginal, +use solana_sdk::{ + derivation_path::DerivationPath, + signature::keypair_from_seed_and_derivation_path, + signer::{ + keypair::{ + keypair_from_seed, keypair_from_seed_phrase_and_passphrase, Keypair as KeypairOriginal, + }, + Signer as SignerTrait, }, - Signer as SignerTrait, }; use solders_macros::{common_methods, pyhash, richcmp_signer}; use solders_pubkey::Pubkey; @@ -193,6 +197,30 @@ impl Keypair { handle_py_value_err(keypair_from_seed(&seed)) } + #[staticmethod] + /// Generate a keypair from a 32-byte seed and derivation path.. + /// + /// Args: + /// seed (bytes): 32-byte seed. + /// dpath (str): derivation path. + /// Returns: + /// Keypair: The generated keypair. + /// + /// Example: + /// >>> from solders.keypair import Keypair + /// >>> from solders.pubkey import Pubkey + /// >>> seed_bytes = bytes([0] * 64) + /// >>> account_index = 0 + /// >>> derivation_path = f"m/44'/501'/0'/{account_index}'" + /// >>> from_seed = Keypair.from_seed_and_derivation_path(seed_bytes, derivation_path) + /// + pub fn from_seed_and_derivation_path(seed: [u8; 64], dpath: &str) -> PyResult { + handle_py_value_err(keypair_from_seed_and_derivation_path( + &seed, + Some(DerivationPath::from_absolute_path_str(dpath).unwrap()), + )) + } + #[staticmethod] /// Generate a keypair from a seed phrase and passphrase. /// diff --git a/noxfile.py b/noxfile.py index b01ec296..6d5732d0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -14,6 +14,7 @@ def python(session): "typing-extensions", "jsonalias", "myst-parser", + "mnemonic", ) session.install(".", "--no-build-isolation") session.run("make", "test", external=True) diff --git a/poetry.lock b/poetry.lock index 843b4ad9..9022a575 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,18 +31,17 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte [[package]] name = "babel" -version = "2.13.1" +version = "2.14.0" description = "Internationalization utilities" optional = false python-versions = ">=3.7" files = [ - {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"}, - {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"}, + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, ] [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} -setuptools = {version = "*", markers = "python_version >= \"3.12\""} [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -245,13 +244,13 @@ test = ["pytest (>=6)"] [[package]] name = "idna" -version = "3.5" +version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.5-py3-none-any.whl", hash = "sha256:79b8f0ac92d2351be5f6122356c9a592c96d81c9a79e4b488bf2a6a15f88057a"}, - {file = "idna-3.5.tar.gz", hash = "sha256:27009fe2735bf8723353582d48575b23c533cc2c2de7b5a68908d91b5eb18d08"}, + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] [[package]] @@ -267,20 +266,20 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.8.0" +version = "7.0.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, - {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, + {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, + {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] @@ -475,6 +474,17 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "mnemonic" +version = "0.20" +description = "Implementation of Bitcoin BIP-0039" +optional = false +python-versions = ">=3.5" +files = [ + {file = "mnemonic-0.20-py3-none-any.whl", hash = "sha256:acd2168872d0379e7a10873bb3e12bf6c91b35de758135c4fbd1015ef18fafc5"}, + {file = "mnemonic-0.20.tar.gz", hash = "sha256:7c6fb5639d779388027a77944680aee4870f0fcd09b1e42a5525ee2ce4c625f6"}, +] + [[package]] name = "mypy" version = "0.991" @@ -786,22 +796,6 @@ files = [ {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"}, ] -[[package]] -name = "setuptools" -version = "69.0.2" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, - {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "snowballstemmer" version = "2.2.0" @@ -983,13 +977,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.8.0" +version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, - {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, ] [[package]] @@ -1026,4 +1020,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "31e801002de89ed6321d745d62e5b519def98466c534666734f1338a74f44cf3" +content-hash = "d0470829d3b22b9138fb1d1c75c390e820d209a7e66d4b3de30d259912941163" diff --git a/pyproject.toml b/pyproject.toml index abe450b6..c20c8591 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ based58 = "0.1.1" bumpversion = "0.6.0" "jsonalias" = "0.1.1" ruff = "0.1.6" +mnemonic = "0.20" mypy = "0.991" pytest = "7.2.1" pytest-asyncio = "^0.20.3" diff --git a/python/solders/keypair.pyi b/python/solders/keypair.pyi index fb5dfb32..8803492f 100644 --- a/python/solders/keypair.pyi +++ b/python/solders/keypair.pyi @@ -12,6 +12,10 @@ class Keypair: @staticmethod def from_seed(seed: Union[bytes, Sequence[int]]) -> "Keypair": ... @staticmethod + def from_seed_and_derivation_path( + seed: Union[bytes, Sequence[int]], dpath: str + ) -> "Keypair": ... + @staticmethod def from_base58_string(s: str) -> "Keypair": ... @staticmethod def from_seed_phrase_and_passphrase( diff --git a/tests/test_account.py b/tests/test_account.py index 5d727be7..37773110 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -21,6 +21,7 @@ def test_pickle(account: Account) -> None: def test_json(account: Account) -> None: assert Account.from_json(account.to_json()) == account + def test_account_from_json() -> None: # https://github.com/kevinheavey/solders/issues/69 raw = """{ diff --git a/tests/test_keypair.py b/tests/test_keypair.py index 4932c200..77402954 100644 --- a/tests/test_keypair.py +++ b/tests/test_keypair.py @@ -2,11 +2,21 @@ from operator import ge, gt, le, lt from typing import Any, Callable +import mnemonic from pybip39 import Mnemonic, Seed from pytest import mark, raises from solders.keypair import Keypair +def test_from_seed_and_derivation_path() -> None: + mnemo = mnemonic.Mnemonic("english") + seed = mnemo.to_seed( + "pill tomorrow foster begin walnut borrow virtual kick shift mutual shoe scatter" + ) + pubkey = Keypair.from_seed_and_derivation_path(seed, "m/44'/501'/0'/0'").pubkey() + assert str(pubkey) == "5F86TNSTre3CYwZd1wELsGQGhqG2HkN3d8zxhbyBSnzm" + + def test_from_bytes() -> None: raw_bytes = ( b"\x99\xda\x95Y\xe1^\x91>\xe9\xab.S\xe3\xdf\xadW]\xa3;I\xbe\x11%\xbb\x92.3IOI" diff --git a/tests/test_rpc_responses.py b/tests/test_rpc_responses.py index b1d308f9..bfaa2e2f 100644 --- a/tests/test_rpc_responses.py +++ b/tests/test_rpc_responses.py @@ -1191,6 +1191,7 @@ def test_get_program_accounts_without_context() -> None: pubkey=Pubkey.from_string("CxELquR1gPP8wHe33gZ4QxqGB3sZ9RSwsJ2KshVewkFY"), ) + def test_keyed_account_from_json() -> None: raw = """{ "pubkey": "7wZpAKYM1uygtosoF42V4a5tVLsrzpSN6Uedaxc6vGrQ",