From 4f299d76684abf37eb9cda6691eb52adb0bcdda8 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 29 Sep 2026 01:11:18 -0500 Subject: [PATCH 1/4] Remove Python wallet test dependencies Remove the remaining bip32/Coincurve test-only oracle and CI install path, keep Bitcoin Core-derived fingerprint fixtures as frozen test data, and retain real-Core integration coverage without loading a Python secp256k1 implementation. Carry the reviewed Python 3.10 through 3.15 compatibility work in the same integration patch. Closes #3 Closes #18 Refs #6 --- .github/workflows/python-package.yml | 22 ++++-- AGENTS.md | 5 +- README.md | 2 +- docs/security/model.md | 2 +- pyproject.toml | 8 ++- requirements/test-wallet-dependencies.txt | 30 -------- src/codex32/generation.py | 4 +- src/codex32/profiles/__init__.py | 10 +-- src/codex32/profiles/bip39.py | 2 +- tests/data/README.md | 3 + tests/data/wallet_fingerprints.json | 15 ++++ tests/test_bip93.py | 4 ++ tests/test_cli.py | 28 ++++---- tests/test_correction_indel.py | 8 +-- tests/test_generation.py | 25 +++---- tests/test_generic_hrp.py | 8 ++- tools/_wallet_reference.py | 72 ------------------- tools/_wallet_test_vectors.py | 55 +++++++++++++++ tools/bitcoin_core_regtest.py | 5 ++ tools/differential_wallet.py | 85 ----------------------- 20 files changed, 158 insertions(+), 235 deletions(-) delete mode 100644 requirements/test-wallet-dependencies.txt create mode 100644 tests/data/wallet_fingerprints.json delete mode 100644 tools/_wallet_reference.py create mode 100644 tools/_wallet_test_vectors.py delete mode 100644 tools/differential_wallet.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5943287..1fe15af 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -25,9 +25,6 @@ jobs: python-version: ${{ matrix.python-version }} - run: python -m pip install --upgrade pip - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt - - run: >- - python -m pip install --no-build-isolation --require-hashes - -r requirements/test-wallet-dependencies.txt - run: python -m pip install --no-build-isolation -e '.[dev]' - run: python -m pip check - run: python -m pytest -q @@ -39,6 +36,23 @@ jobs: - run: python -m ruff check . - run: python -m ruff format --check . - run: python tools/differential_correction.py --verify - - run: python tools/differential_wallet.py --verify - run: python -m build --no-isolation - run: python tools/verify_wheel_environment.py + + compatibility: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.14", "3.15"] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: ${{ matrix.python-version == '3.15' }} + - run: python -m pip install --upgrade pip + - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt + - run: python -m pip install --no-build-isolation -e '.[dev]' + - run: python -m pip check + - run: python -m pytest -q diff --git a/AGENTS.md b/AGENTS.md index d3a62a2..fd47d95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,9 @@ contains offline verification utilities. ## Development and verification -Use the existing virtual environment when available. Python 3.12 is the minimum; -CI also covers 3.13. Install development dependencies only when needed: +Use the existing virtual environment when available. Python 3.10 is the minimum; +the supported range is Python 3.10 through 3.15. Install development +dependencies only when needed: `python -m pip install -e '.[dev]'`. Run the CLI with `codex32 --help`. Choose checks according to the changed behavior: diff --git a/README.md b/README.md index e652578..604201f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ obtain an independent review before relying on it with funds. See ## Install -Python 3.12 or 3.13 is required. The installed package has no third-party +Python 3.10 through 3.15 is supported. The installed package has no third-party runtime dependencies. To install it with the pinned build backend, run these commands from the project folder: diff --git a/docs/security/model.md b/docs/security/model.md index 70cbbd8..de89425 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -252,5 +252,5 @@ initialization. | Parsing and profiles | [`test_bech32.py`](../../tests/test_bech32.py), [`test_bip93.py`](../../tests/test_bip93.py), and [`test_profiles.py`](../../tests/test_profiles.py) | | Creation, sharing, and recovery | [`test_generation.py`](../../tests/test_generation.py), [`test_sharing.py`](../../tests/test_sharing.py), and the BIP93 vectors under `tests/data/` | | Correction | [`test_correction_bch.py`](../../tests/test_correction_bch.py), [`test_correction_indel.py`](../../tests/test_correction_indel.py), [`correction_capture.py`](../../tools/correction_capture.py), and [`differential_correction.py --verify`](../../tools/differential_correction.py) | -| Bitcoin Core and wallets | [`test_bitcoin_core.py`](../../tests/test_bitcoin_core.py), [`test_wallet.py`](../../tests/test_wallet.py), [`bitcoin_core_regtest.py`](../../tools/bitcoin_core_regtest.py), and [`differential_wallet.py`](../../tools/differential_wallet.py) | +| Bitcoin Core and wallets | [`test_bitcoin_core.py`](../../tests/test_bitcoin_core.py), [`test_wallet.py`](../../tests/test_wallet.py), [`bitcoin_core_regtest.py`](../../tools/bitcoin_core_regtest.py), and [`bitcoin_core_main_smoke.py`](../../tools/bitcoin_core_main_smoke.py) | | CLI channels and input | [`test_cli.py`](../../tests/test_cli.py) | diff --git a/pyproject.toml b/pyproject.toml index 508d90d..ce4a9fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] description = "Python reference implementation for codex32 (BIP93) and codex32-encoded master seeds." readme = "README.md" -requires-python = ">=3.12,<3.14" +requires-python = ">=3.10,<3.16" license = "MIT AND BSD-3-Clause" license-files = ["LICENSE*", "LICENSES/*"] maintainers = [ @@ -22,8 +22,12 @@ classifiers = [ "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Operating System :: OS Independent", "Topic :: Security :: Cryptography", "Topic :: Software Development :: Libraries", @@ -52,7 +56,7 @@ where = ["src"] testpaths = ["tests"] [tool.mypy] -python_version = "3.12" +python_version = "3.10" strict = true [tool.ruff] diff --git a/requirements/test-wallet-dependencies.txt b/requirements/test-wallet-dependencies.txt deleted file mode 100644 index cff377f..0000000 --- a/requirements/test-wallet-dependencies.txt +++ /dev/null @@ -1,30 +0,0 @@ ---require-hashes ---only-binary=coincurve - -# Test-only independent wallet oracle; never installed by the documented CLI path. -# Owner-authored upstream PR #53 changes only coincurve's upper bound and its CI matrix. -bip32 @ https://github.com/darosior/python-bip32/archive/45db547bdf5a5bc19a8c55ef447dbf9169928792.tar.gz \ - --hash=sha256:a25be30641b381eed9a5249bbc4a73124905223c9c50f93140f519bcf259e415 - -# Every published CPython 3.12 and 3.13 wheel for the supported OS families. -coincurve==21.0.0 \ - --hash=sha256:f60ad56113f08e8c540bb89f4f35f44d434311433195ffff22893ccfa335070c \ - --hash=sha256:1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2 \ - --hash=sha256:05d7e255a697b3475d7ae7640d3bdef3d5bc98ce9ce08dd387f780696606c33b \ - --hash=sha256:5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4 \ - --hash=sha256:1b04778b75339c6e46deb9ae3bcfc2250fbe48d1324153e4310fc4996e135715 \ - --hash=sha256:8efcbdcd50cc219989a2662e6c6552f455efc000a15dd6ab3ebf4f9b187f41a3 \ - --hash=sha256:6df44b4e3b7acdc1453ade52a52e3f8a5b53ecdd5a06bd200f1ec4b4e250f7d9 \ - --hash=sha256:bcc0831f07cb75b91c35c13b1362e7b9dc76c376b27d01ff577bec52005e22a8 \ - --hash=sha256:5dd7b66b83b143f3ad3861a68fc0279167a0bae44fe3931547400b7a200e90b1 \ - --hash=sha256:78dbe439e8cb22389956a4f2f2312813b4bd0531a0b691d4f8e868c7b366555d \ - --hash=sha256:9df5ceb5de603b9caf270629996710cf5ed1d43346887bc3895a11258644b65b \ - --hash=sha256:154467858d23c48f9e5ab380433bc2625027b50617400e2984cc16f5799ab601 \ - --hash=sha256:f57f07c44d14d939bed289cdeaba4acb986bba9f729a796b6a341eab1661eedc \ - --hash=sha256:3fb03e3a388a93d31ed56a442bdec7983ea404490e21e12af76fb1dbf097082a \ - --hash=sha256:d09ba4fd9d26b00b06645fcd768c5ad44832a1fa847ebe8fb44970d3204c3cb7 \ - --hash=sha256:1a1e7ee73bc1b3bcf14c7b0d1f44e6485785d3b53ef7b16173c36d3cefa57f93 \ - --hash=sha256:ad05952b6edc593a874df61f1bc79db99d716ec48ba4302d699e14a419fe6f51 \ - --hash=sha256:4d2bf350ced38b73db9efa1ff8fd16a67a1cb35abb2dda50d89661b531f03fd3 \ - --hash=sha256:54d9500c56d5499375e579c3917472ffcf804c3584dd79052a79974280985c74 \ - --hash=sha256:773917f075ec4b94a7a742637d303a3a082616a115c36568eb6c873a8d950d18 diff --git a/src/codex32/generation.py b/src/codex32/generation.py index db1843f..f135d19 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass -from typing import Never, SupportsIndex, cast +from typing import NoReturn, SupportsIndex, cast from codex32._bip32 import _valid_root from codex32.bech32 import CHARSET, _u5_to_chars, convertbits @@ -201,7 +201,7 @@ class CreationCeremony: def __init__(self) -> None: raise TypeError("use a CreationCeremony class constructor") - def __reduce_ex__(self, _protocol: SupportsIndex) -> Never: + def __reduce_ex__(self, _protocol: SupportsIndex) -> NoReturn: raise TypeError("creation ceremonies cannot be copied or serialized") @classmethod diff --git a/src/codex32/profiles/__init__.py b/src/codex32/profiles/__init__.py index 89e053d..3293130 100644 --- a/src/codex32/profiles/__init__.py +++ b/src/codex32/profiles/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from enum import StrEnum -from typing import TYPE_CHECKING +from enum import Enum +from typing import TYPE_CHECKING, TypeAlias, Union from codex32.errors import UnknownProfile @@ -11,10 +11,12 @@ from codex32.profiles.bip39 import _Bip39Rules from codex32.profiles.cl32 import _Cl32Rules from codex32.profiles.ms32 import _Ms32Rules -type _ProfileRules = _Ms32Rules | _Cl32Rules | _Bip39Rules +_ProfileRules: TypeAlias = Union["_Ms32Rules", "_Cl32Rules", "_Bip39Rules"] -class Profile(StrEnum): +class Profile(str, Enum): + __str__ = str.__str__ + MS = "ms" CL = "cl" BIP39_12W = "bip39_12w" diff --git a/src/codex32/profiles/bip39.py b/src/codex32/profiles/bip39.py index c4994f5..5ebc5ed 100644 --- a/src/codex32/profiles/bip39.py +++ b/src/codex32/profiles/bip39.py @@ -57,7 +57,7 @@ def validate_payload(self, symbols: tuple[int, ...], index: str) -> None: semantic = value >> self.outer_padding embedded = semantic & ((1 << self.checksum_bits) - 1) entropy = semantic >> self.checksum_bits - entropy_bytes = entropy.to_bytes(self.entropy_bits // 8) + entropy_bytes = entropy.to_bytes(self.entropy_bits // 8, "big") expected = hashlib.sha256(entropy_bytes).digest()[0] >> (8 - self.checksum_bits) if embedded != expected: raise InvalidBip39Checksum("embedded BIP39 entropy checksum is invalid") diff --git a/tests/data/README.md b/tests/data/README.md index c5c23cd..fa57ecc 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -7,6 +7,9 @@ - `p70_correction_vectors.json` is the frozen PR #70 correction corpus. - `malformed_inputs.json` freezes independent rejection cases for parsing, interpolation, correction, and CLI tokenization. +- `wallet_fingerprints.json` freezes BIP32 master fingerprints derived by real + Bitcoin Core. `tools/_wallet_test_vectors.py` owns lookup/stub behavior, while + `tools/bitcoin_core_regtest.py` independently verifies every frozen value. Expected values are not generated by the production implementation during tests. diff --git a/tests/data/wallet_fingerprints.json b/tests/data/wallet_fingerprints.json new file mode 100644 index 0000000..287f68c --- /dev/null +++ b/tests/data/wallet_fingerprints.json @@ -0,0 +1,15 @@ +{ + "bip93_vector_seeds": { + "318c6318c6318c6318c6318c6318c631": "3f3521a6", + "d1808e096b35b209ca12132b264662a5": "fab6868a", + "ffeeddccbbaa99887766554433221100": "1e50c111" + }, + "fixture_seeds": { + "107dea57c4319e0b78e552bf2c990673": "8ed1dab8", + "1481ee5bc835a20f7ce956c3309d0a77e451be2b": "c9a9f9c8", + "1885f25fcc39a61380ed5ac734a10e7be855c22f9c0976e3": "4dc66f0e", + "1c89f663d03daa1784f15ecb38a5127fec59c633a00d7ae754c12e9b": "a5f9e972", + "208dfa67d441ae1b88f562cf3ca91683f05dca37a4117eeb58c5329f0c79e653": "9653e4ec", + "40ad1a87f461ce3ba81582ef5cc936a3107dea57c4319e0b78e552bf2c990673e04dba2794016edb48b5228ffc69d643b01d8af764d13eab1885f25fcc39a613": "a9da6294" + } +} diff --git a/tests/test_bip93.py b/tests/test_bip93.py index c8b3999..a57b1f0 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -32,6 +32,10 @@ from codex32.errors import CodexError, InvalidChecksum +def test_profile_string_matches_its_value() -> None: + assert str(Profile.MS) == "ms" + + def test_vector_1_parts_and_seed() -> None: secret = parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d50f4f..441d19e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,7 +37,7 @@ from codex32.cli import main, ms_main from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import SEED_BYTE_LENGTHS -from tools._wallet_reference import fingerprint_seed +from tools._wallet_test_vectors import stub_fingerprint @dataclass(frozen=True) @@ -78,14 +78,12 @@ def isatty(self) -> bool: class _CreationOutput(io.StringIO): - def __init__(self, *, pretty: bool = False) -> None: + def __init__(self) -> None: super().__init__() - self.pretty = pretty - self.checks = 0 + self.interactive = True def isatty(self) -> bool: - self.checks += 1 - return self.pretty or self.checks == 1 + return self.interactive @dataclass @@ -98,7 +96,7 @@ class _FakeBitcoinCore: timestamp: int | str | None = None def fingerprint_seed(self, seed: bytes) -> bytes: - return fingerprint_seed(seed) + return stub_fingerprint(seed) def fingerprint(self, secret: MasterSeed) -> bytes: return self.fingerprint_seed(secret.seed_bytes) @@ -151,12 +149,16 @@ def _invoke_terminal(args: list[str], *lines: str) -> _Result: def _invoke_confirmed_create( args: list[str], *lines: str, - terminal_output: bool = False, core: _FakeBitcoinCore | None = None, ) -> _Result: stdin = _TTYInput("\n".join(lines) + "\n") - stdout = _CreationOutput(pretty=terminal_output) + stdout = _CreationOutput() stderr = io.StringIO() + selected_core = core or _FakeBitcoinCore() + + def connect(*_args: object, **_kwargs: object) -> _FakeBitcoinCore: + stdout.interactive = False + return selected_core def confirm_card( artifact: Share | Secret, @@ -169,7 +171,7 @@ def confirm_card( with ( patch.object(sys, "stdin", stdin), patch("codex32.cli._confirm_card", confirm_card), - patch("codex32.cli.BitcoinCore.connect", return_value=core or _FakeBitcoinCore()), + patch("codex32.cli.BitcoinCore.connect", side_effect=connect), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ): @@ -1224,7 +1226,7 @@ def test_create_defaults_to_an_unshared_128_bit_master_seed() -> None: secret = artifacts[0] assert isinstance(secret, MasterSeed) and len(secret.seed_bytes) == 16 assert secret.header.threshold == 0 - assert secret.header.identifier == _fingerprint_identifier(fingerprint_seed(secret.seed_bytes)) + assert secret.header.identifier == _fingerprint_identifier(stub_fingerprint(secret.seed_bytes)) def test_fresh_bitcoin_terminal_and_core_preflight_precede_entropy() -> None: @@ -1360,7 +1362,7 @@ def answer(prompt: str) -> str: assert ms_main(["create"]) == 0 artifact = parse_codex32(emitted[0]) assert isinstance(artifact, MasterSeed) - assert artifact.header.identifier == _fingerprint_identifier(fingerprint_seed(artifact.seed_bytes)) + assert artifact.header.identifier == _fingerprint_identifier(stub_fingerprint(artifact.seed_bytes)) def test_fresh_shared_create_confirms_each_card_on_a_terminal( @@ -1549,7 +1551,7 @@ def test_create_accepts_positional_headers_and_preserves_index_order() -> None: shares = _output_artifacts(shared) assert isinstance(fingerprinted_secret, MasterSeed) assert fingerprinted_secret.header.identifier == _fingerprint_identifier( - fingerprint_seed(fingerprinted_secret.seed_bytes) + stub_fingerprint(fingerprinted_secret.seed_bytes) ) assert unshared_secret.header.identifier == "test" assert len(automatic) == 3 diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index 9dcb99a..05d6d97 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -32,7 +32,7 @@ _search_target, ) from codex32.profiles.ms32 import TEXT_LENGTHS -from tools._wallet_reference import fingerprint_seed +from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint from tools.correction_capture import cross_length_classes SOURCE = VECTOR_1["secret_s"] @@ -410,15 +410,15 @@ def test_duplicate_reconstruction_keeps_lower_hamming_path() -> None: def test_cli_tie_breaks_follow_hamming_crc_then_fingerprint() -> None: - seed = bytes(range(16)) - fingerprint = MasterSeed.from_seed(seed, identifier=_fingerprint_identifier(fingerprint_seed(seed))) + seed = FIXTURE_SEED[16] + fingerprint = MasterSeed.from_seed(seed, identifier=_fingerprint_identifier(core_fingerprint(seed))) mismatch = MasterSeed.from_seed(seed, identifier="test") high_hamming = CorrectionCandidate(mismatch, (), 10, 0, 3, True) crc = CorrectionCandidate(mismatch, (), 10, 0, 2, True) fingerprint_match = CorrectionCandidate(fingerprint, (), 10, 0, 2, True) assert len(_primary((high_hamming, crc, fingerprint_match))) == 3 - matcher = _fingerprint_matcher(lambda secret: fingerprint_seed(secret.seed_bytes)) + matcher = _fingerprint_matcher(lambda secret: core_fingerprint(secret.seed_bytes)) assert _best((high_hamming, crc, fingerprint_match), fingerprint_match=matcher) == (fingerprint_match,) diff --git a/tests/test_generation.py b/tests/test_generation.py index 289662b..878e795 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -6,7 +6,7 @@ import pytest from _codex32_oracle import oracle_encode -from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_3, VECTOR_4, VECTOR_6 +from data.bip93_vectors import VECTOR_2, VECTOR_4, VECTOR_6 from data.sharing_vectors import SHARING_VECTORS from hypothesis import given, settings from hypothesis import strategies as st @@ -34,7 +34,7 @@ ) from codex32.generation import ORDINARY_INDICES, _fingerprint_identifier from codex32.profiles.ms32 import SEED_BYTE_LENGTHS, _has_generation_padding -from tools._wallet_reference import fingerprint_seed +from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint, stub_fingerprint def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed | CoreLightningSecret, tuple[Share, ...]]: @@ -60,13 +60,22 @@ def _seed(byte_length: int) -> bytes: def test_fresh_unshared_ms_supports_every_bip93_size() -> None: for byte_length in SEED_BYTE_LENGTHS: - secret = generate_master_seed(byte_length=byte_length, fingerprint=fingerprint_seed) + secret = generate_master_seed(byte_length=byte_length, fingerprint=stub_fingerprint) assert len(secret.seed_bytes) == byte_length assert secret.header.threshold == 0 - assert secret.header.identifier == _fingerprint_identifier(fingerprint_seed(secret.seed_bytes)) + assert secret.header.identifier == _fingerprint_identifier(stub_fingerprint(secret.seed_bytes)) assert _has_generation_padding(secret) +@pytest.mark.parametrize( + ("byte_length", "expected"), + ((16, "3mga"), (20, "ex5l"), (24, "fhrx"), (28, "5hu7"), (32, "jef7"), (64, "48dx")), +) +def test_unshared_identifier_from_core_fingerprint(byte_length: int, expected: str) -> None: + seed = FIXTURE_SEED[byte_length] + assert _fingerprint_identifier(core_fingerprint(seed)) == expected + + def test_fresh_shared_ms_supports_every_bip93_size() -> None: for byte_length in SEED_BYTE_LENGTHS: secret, shares = _complete( @@ -402,11 +411,3 @@ def test_from_secret_rejects_non_secret_artifacts() -> None: indices="ac", identifier="test", # type: ignore[arg-type] ) - - -@pytest.mark.parametrize( - ("vector", "expected"), - ((VECTOR_1, "8u6j"), (VECTOR_2, "l2mg"), (VECTOR_3, "regv")), -) -def test_unshared_fingerprint_identifier_vectors(vector: dict[str, str], expected: str) -> None: - assert _fingerprint_identifier(fingerprint_seed(bytes.fromhex(vector["secret_hex"]))) == expected diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 341e504..c4dc3c4 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -19,6 +19,7 @@ ) from codex32.cli import main, ms_main from codex32.errors import MismatchedHrp, MismatchedProfile +from tools._wallet_test_vectors import STUB_FINGERPRINT UNKNOWN = { "short": { @@ -167,9 +168,12 @@ def test_cli_split_and_unknown_neutral_summary() -> None: def test_cli_share_is_generic_and_ms32_share_is_scoped(monkeypatch) -> None: - from tools._wallet_reference import ReferenceCore + class _FakeCore: + @staticmethod + def fingerprint(_secret: object) -> bytes: + return STUB_FINGERPRINT - monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: ReferenceCore()) + monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: _FakeCore()) basis = UNKNOWN["short"]["S"] + "\n" + UNKNOWN["short"]["A"] + "\n" status, output, error = _invoke(main, ["share", "d", "--plain"], basis) assert (status, output.strip(), error) == (0, UNKNOWN["short"]["D"], "") diff --git a/tools/_wallet_reference.py b/tools/_wallet_reference.py deleted file mode 100644 index 796efa8..0000000 --- a/tools/_wallet_reference.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Test-only Core descriptor oracle backed by the independent bip32 package. - -Never use this adapter with real wallet material. Runtime code delegates public -derivation to Bitcoin Core; this oracle keeps offline vectors reproducible. -""" - -import re -from typing import Literal - -from bip32 import BIP32 - -from codex32._bitcoin_core import BitcoinCore -from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import _with_checksum, core_descriptors - - -def fingerprint_seed(seed: bytes) -> bytes: - return BIP32.from_seed(seed).get_fingerprint() - - -def descriptor_info(descriptor: str) -> dict[str, object]: - descriptor = descriptor.strip().split("#", 1)[0] - - def public(match: re.Match[str]) -> str: - root = BIP32.from_xpriv(match.group(1)) - path = match.group(2) - return f"[{root.get_fingerprint().hex()}{path}]{root.get_xpub_from_path('m' + path)}" - - normalized = re.sub(r"([xt]prv[1-9A-HJ-NP-Za-km-z]+)((?:/\d+h)*)", public, descriptor) - return { - "descriptor": _with_checksum(normalized), - "multipath_expansion": [ - _with_checksum(normalized.replace("<0;1>", str(branch))) for branch in (0, 1) - ], - } - - -class ReferenceCore(BitcoinCore): - def __init__(self, *, testnet: bool = False) -> None: - super().__init__("test-only", "test" if testnet else "main", 320000) - - def fingerprint(self, secret: MasterSeed) -> bytes: - return fingerprint_seed(secret.seed_bytes) - - def public_descriptors( - self, - secret: MasterSeed, - *, - wallet: str, - account: int = 0, - timestamp: int | Literal["now"] = 0, - ) -> tuple[dict[str, object], ...]: - del wallet - private = core_descriptors( - secret, - account=account, - testnet=self.chain != "main", - private=True, - timestamp=timestamp, - ) - return tuple( - { - "desc": descriptor_info(str(record["desc"]))["descriptor"], - "active": True, - "timestamp": timestamp, - } - for record in private - ) - - def _rpc(self, *arguments: str, wallet: str | None = None, stdin: str | None = None) -> object: - assert arguments == ("getdescriptorinfo",) and wallet is None and stdin is not None - return descriptor_info(stdin) diff --git a/tools/_wallet_test_vectors.py b/tools/_wallet_test_vectors.py new file mode 100644 index 0000000..abdb2ce --- /dev/null +++ b/tools/_wallet_test_vectors.py @@ -0,0 +1,55 @@ +"""Bitcoin Core-derived public wallet fixtures used by tests and integration checks. + +Everything in ``tests/data/wallet_fingerprints.json`` is a BIP32 master fingerprint, +which is a wallet property and not a codex32 one. Some seeds there also appear as +BIP93 test vectors, but only as convenient public seed material: BIP93 assigns those +vectors identifiers of its own (``test``, ``name``, ``cash``) that have nothing to do +with these fingerprints. An identifier derived from a fingerprint is therefore never +a BIP93-specified value, so assert such identifiers against ``FIXTURE_SEED`` rather +than against vector material. + +``tools/bitcoin_core_regtest.py`` verifies every frozen fingerprint against real Core. +""" + +import hashlib +import json +from pathlib import Path + +_DATA = json.loads( + (Path(__file__).resolve().parents[1] / "tests" / "data" / "wallet_fingerprints.json").read_text() +) + + +def _fingerprints(group: str) -> dict[bytes, bytes]: + return {bytes.fromhex(seed): bytes.fromhex(fingerprint) for seed, fingerprint in _DATA[group].items()} + + +# BIP93 vector seeds, reused here only as wallet fixtures. +_VECTOR_FINGERPRINTS = _fingerprints("bip93_vector_seeds") + +# Arbitrary fixtures, one per BIP93 seed length, carrying no test-vector meaning. +_FIXTURE_FINGERPRINTS = _fingerprints("fixture_seeds") + +CORE_FINGERPRINTS = _VECTOR_FINGERPRINTS | _FIXTURE_FINGERPRINTS + +# Arbitrary fixture seed by byte length; excludes vector material by construction. +FIXTURE_SEED = {len(seed): seed for seed in _FIXTURE_FINGERPRINTS} + +# Stands in where the fingerprint value itself is irrelevant to the test. +STUB_FINGERPRINT = b"\x00\x00\x00\x01" + + +def core_fingerprint(seed: bytes) -> bytes: + """Return a frozen fingerprint that the real-Core regtest verifies.""" + try: + return CORE_FINGERPRINTS[seed] + except KeyError as error: + raise AssertionError("missing Bitcoin Core fingerprint fixture") from error + + +def stub_fingerprint(seed: bytes) -> bytes: + """Return real fixture data when known, otherwise a seed-sensitive test double.""" + fixture = CORE_FINGERPRINTS.get(seed) + if fixture is not None: + return fixture + return hashlib.sha256(b"codex32 test fingerprint\0" + seed).digest()[:4] diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 4057e0e..d9a70d2 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import Any +from _wallet_test_vectors import CORE_FINGERPRINTS + from codex32._bitcoin_core import BitcoinCore from codex32.bip93 import parse_codex32 from codex32.profiles.ms32 import MasterSeed @@ -123,6 +125,9 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if not isinstance(secret, MasterSeed): raise TypeError("synthetic fixture was not a master seed") client = BitcoinCore.connect() + for seed, expected_fingerprint in CORE_FINGERPRINTS.items(): + if client.fingerprint_seed(seed) != expected_fingerprint: + raise RuntimeError("Bitcoin Core fingerprint fixture mismatch") answers = iter(("yes",)) if ( client.initialize( diff --git a/tools/differential_wallet.py b/tools/differential_wallet.py deleted file mode 100644 index f8f8f23..0000000 --- a/tools/differential_wallet.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Emit a deterministic digest of the wallet interoperability boundary.""" - -from __future__ import annotations - -import argparse -import hashlib -import json - -from _wallet_reference import ReferenceCore - -from codex32 import MasterSeed, core_descriptors, master_xprv - -_DOMAIN = b"python-codex32 differential wallet corpus v2" -_SEED_LENGTHS = (16, 20, 24, 28, 32, 64) -_EXPECTED_CASES = 64 -_EXPECTED_DIGEST = "9b3342af401765e4ec73acb3d17142060c1844fc70fda4d87e77f1191f6aec40" - - -def _seed(case: int, length: int) -> bytes: - material = _DOMAIN + case.to_bytes(4, "big") + bytes([length]) - return hashlib.sha512(material).digest()[:length] - - -def _record(case: int, length: int, testnet: bool) -> dict[str, object]: - seed = _seed(case, length) - account = int.from_bytes(hashlib.sha256(seed).digest()[:4], "big") % 2**31 - secret = MasterSeed.from_seed(seed, identifier="test") - core = ReferenceCore(testnet=testnet) - return { - "case": case, - "length": length, - "testnet": testnet, - "account": account, - "xprv": master_xprv(secret, testnet=testnet), - "public": core_descriptors( - secret, - integration=core, - wallet="test-only", - account=account, - testnet=testnet, - timestamp=case, - ), - "private": core_descriptors( - secret, - account=account, - testnet=testnet, - private=True, - timestamp=case, - ), - } - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--cases", type=int, default=_EXPECTED_CASES) - parser.add_argument("--verify", action="store_true") - arguments = parser.parse_args() - if arguments.cases < 1: - raise SystemExit("--cases must be positive") - - digest = hashlib.sha256() - records = 0 - for case in range(arguments.cases): - for length in _SEED_LENGTHS: - for testnet in (False, True): - encoded = json.dumps( - _record(case, length, testnet), - sort_keys=True, - separators=(",", ":"), - ).encode() - digest.update(len(encoded).to_bytes(4, "big")) - digest.update(encoded) - records += 1 - - actual = digest.hexdigest() - print(json.dumps({"records": records, "sha256": actual})) - if arguments.verify: - if arguments.cases != _EXPECTED_CASES: - raise SystemExit(f"--verify requires --cases {_EXPECTED_CASES}") - if not _EXPECTED_DIGEST or actual != _EXPECTED_DIGEST: - raise SystemExit("wallet differential digest mismatch") - - -if __name__ == "__main__": - main() From f9c3bc8681c74da0e1372d30502fd7c4a302b288 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 30 Sep 2026 00:18:16 -0500 Subject: [PATCH 2/4] Delegate wallet setup to Bitcoin Core Pass the validated master xprv to addhdkey over stdin and let Core create the four standard account-0 descriptor types. This removes the redundant exact public-descriptor comparison while preserving destination revalidation, historical recovery, and encrypted-wallet relocking. Restrict CLI account selection to zero until Core exposes a native selector (refs #68). Validated with 874 normal and 874 optimized tests, Ruff, mypy, and isolated Bitcoin Core 32.0rc2 regtest and main-chain fixtures. --- docs/developer/api.md | 61 +++++++------- docs/security/invariants.md | 6 +- docs/security/model.md | 10 +-- docs/user/guide.md | 4 +- src/codex32/_bitcoin_core.py | 88 ++++++++------------ src/codex32/_cli_parser.py | 4 +- src/codex32/cli.py | 6 +- tests/test_bitcoin_core.py | 134 ++++++++++++++++--------------- tests/test_cli.py | 13 ++- tests/test_generic_hrp.py | 2 +- tools/bitcoin_core_main_smoke.py | 24 +----- tools/bitcoin_core_regtest.py | 27 ------- 12 files changed, 161 insertions(+), 218 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index fef8079..1a8286b 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -588,10 +588,9 @@ The public adapter has two functions: Core wallet whose imported root key will perform hardened derivation. No installed Python dependency performs secp256k1 operations. The private -Bitcoin Core adapter implements the wallet integration by sending root-xprv -descriptor material to `bitcoin-cli` over stdin and treating Core's returned -fingerprints, account xpubs, and normalized descriptors as untrusted external -data. +Bitcoin Core adapter gives Core the root xprv over stdin and asks Core to +create the four standard account-0 descriptor types. Public descriptor +derivation remains available through the explicit integration API. Public descriptors contain account xpubs. Private descriptors intentionally follow Bitcoin Core's root-key form: they contain the root xprv followed by the @@ -599,14 +598,14 @@ complete derivation path. They therefore grant authority over the entire root, not only the selected account. The CLI warns before printing them. Account, private/public mode, network serialization, and timestamp are explicit -API inputs. The `ms32 wallet` CLI takes only `--account` and `--timestamp`; the +API inputs. The `ms32 wallet` CLI takes `--account 0` and `--timestamp`; the selected Bitcoin Core chain is authoritative and there is no wallet `--testnet` flag. `ms32 xprv --testnet` remains explicit because it directly selects xprv versus tprv serialization. The timestamp defaults to `0` so -recovery scans from genesis. A nonnegative Unix time or the literal `now` may -be supplied; `now` intentionally skips historical discovery. There is no -account database, descriptor parser, policy language, RPC library, or network -client. +recovery scans from genesis. Any nonnegative Unix time currently performs a +conservative full rescan; `now` skips historical discovery. Nonzero wallet +accounts await upstream Core support. There is no account database, descriptor +parser, policy language, RPC library, or network client. Bitcoin master-seed creation and restoration use a private CLI adapter. Before entropy or recovery input it resolves `bitcoin-cli` from `PATH` and probes the @@ -619,29 +618,27 @@ descriptors, transactions, keypool, or active scan. The operator selects by number and confirms the escaped exact name; the adapter never infers a wallet from list order or Bitcoin-Qt state. -Immediately before import, every target property is checked again. The original -`CreationCeremony.finish()` result or validated recovered master seed supplies -the four private multipath records. Confirmation text is never reparsed into -this source. Private descriptor material is sent only through -`bitcoin-cli -stdin`, raw Core errors are suppressed, and no passphrase -interface exists. - -After all four private records import successfully, Core v32 exposes the one -wallet HD root with `gethdkeys`; `derivehdkey` performs the hardened -BIP44/49/84/86 account derivations. Python validates the returned origin paths, -fingerprint consistency, and network xpub/tpub versions, constructs only the -fixed descriptor templates, and asks `getdescriptorinfo` to validate and expand -their external/internal branches. The adapter then compares the exact eight -active public descriptors against `listdescriptors`. It relocks wallets Core -reports as encrypted. Master-fingerprint display is likewise delegated to Core: +Immediately before adding the key, every target property is checked again. The +original `CreationCeremony.finish()` result or validated recovered master seed +supplies the root xprv. Confirmation text is never reparsed into this source. +The key is sent only through `bitcoin-cli -stdin`; raw Core errors are suppressed, +and no passphrase interface exists. + +Core v32 accepts the key with `addhdkey` and creates external and internal +account-0 descriptors for BIP44/49/84/86 with `createwalletdescriptor`. Python +checks each call's result but trusts Core to derive and store the wallet policy. +Numeric recovery timestamps run `rescanblockchain` from genesis. The adapter +relocks wallets Core reports as encrypted. Master-fingerprint display is +likewise delegated to Core: a stateless root P2PKH descriptor is normalized, `deriveaddresses` derives its address, and `validateaddress` returns the script hash whose first four bytes are the BIP32 fingerprint. The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`, `getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`, -`validateaddress`, `importdescriptors`, `gethdkeys`, `derivehdkey`, and -`walletlock`. Bitcoin Core alone creates wallets, selects encryption, handles +`validateaddress`, `gethdkeys`, `derivehdkey`, `addhdkey`, +`createwalletdescriptor`, `rescanblockchain`, and `walletlock`. +Bitcoin Core alone creates wallets, selects encryption, handles passphrases, stores keys, and provides normal wallet behavior. The wallet CLI is one leaf command: @@ -651,9 +648,9 @@ ms32 wallet --account 0 --timestamp 0 ``` It preflights Core before recovery input, recovers one validated master seed, -selects and revalidates an empty private-key-enabled destination, imports -through `bitcoin-cli -stdin`, verifies the exact accepted public descriptor -set, and relocks an encrypted destination after success, failure, or +selects and revalidates an empty private-key-enabled destination, sends the root +xprv through `bitcoin-cli -stdin`, asks Core to create account-0 descriptors, +and relocks an encrypted destination after success, failure, or interruption. It never handles a passphrase. For offline signing/watch-only and multisig workflows, use Bitcoin Core v32's @@ -664,11 +661,11 @@ The direct `ms32 xprv` primitive remains top-level and carries an explicit secret-root warning. `tools/bitcoin_core_regtest.py` is the repeatable integration check. It requires -Bitcoin Core 32 or newer and exercises direct wallet restoration, account and -timestamp handling, Core-normalized public descriptors, balance discovery, +Bitcoin Core 32 or newer and exercises direct wallet restoration, account-0 +and timestamp handling, Core-created public descriptors, balance discovery, sign/broadcast behavior on regtest, relocking, and mainnet/test-network root serialization. `tools/bitcoin_core_main_smoke.py` repeats the descriptor, -account, timestamp, and relocking checks against an isolated main-chain Core +account-0, timestamp, and relocking checks against an isolated main-chain Core instance without connecting to peers. ## Deliberate divergences and non-goals diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 5ad144a..98a35ef 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -25,11 +25,11 @@ and evidence. admitted classes ranked equal to or better than the candidate, independently of execution order. 6. Secrets stay out of arguments, logs, ordinary output, and public transfers. - Private descriptors exist only in Python memory and child stdin. + During wallet setup, codex32 transfers the master xprv only through child stdin. 7. Bitcoin Core chains are discovered before entropy or recovery input. The operator confirms an eligible descriptor wallet by exact name. -8. Wallet state is revalidated before import. Every import must succeed and the - exact accepted public descriptor set must match. +8. Wallet state is revalidated before handing Core the master key. Core must + accept that key and create every requested account-0 wallet descriptor. 9. codex32 has no passphrase channel. An unlocked encrypted signer is relocked and verified on every exit path. 10. External text, Core output, public wallet data, and PSBTs are untrusted. diff --git a/docs/security/model.md b/docs/security/model.md index de89425..ceb98db 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -70,8 +70,8 @@ The operator must: - Creation feedback identifies correct groups but does not prove the recovery card was corrected. - A fresh unshared master seed exposes a public 20-bit BIP32 fingerprint in its default identifier; fingerprints are metadata, not secrets. -- Private Bitcoin Core descriptors contain the root xprv and temporarily exist - in Python objects, serialized JSON, and the child process's standard input. +- The master xprv temporarily exists in Python objects and the child process's + standard input during wallet initialization. - Wallet encryption belongs to Bitcoin Core. codex32 accepts an eligible unencrypted or unlocked encrypted wallet and never evaluates or handles a passphrase. @@ -230,9 +230,9 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | -| Seed source | The original ceremony result or validated recovered master seed supplies root-xprv private descriptors for Core's reported chain. After import, Core v32's wallet HD-key RPCs derive the requested BIP44, BIP49, BIP84, and BIP86 account xpubs. | -| Secret channel | Private descriptor JSON is sent only through the child's standard input. It is absent from arguments, ordinary output, and diagnostics. codex32 has no passphrase channel and suppresses raw Core errors. | -| Revalidation | Every destination property is checked again immediately before import. Every private import must succeed before public verification begins. `gethdkeys` must expose one private wallet root; `derivehdkey` must return the requested hardened account paths with one consistent fingerprint and the correct network xpub/tpub version. `getdescriptorinfo` then validates and expands the fixed public templates, and the exact eight active descriptors must match Core's accepted set. | +| Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | +| Secret channel | The master xprv is sent only through the child's standard input. It is absent from arguments, ordinary output, and diagnostics. codex32 has no passphrase channel and suppresses raw Core errors. | +| Revalidation | Every destination property is checked again immediately before adding the key. `addhdkey` must accept it and `createwalletdescriptor` must return two public descriptors for each requested address type. Numeric recovery timestamps trigger a full Core rescan. Core is trusted to derive and store the wallet policy. | | Relocking | Once Core reports an encrypted private-key wallet unlocked, a `finally`-protected obligation requests `walletlock` and verifies the locked state after success, failure, state change, or interruption. | The unlock command is entered in Bitcoin-Qt. Its diff --git a/docs/user/guide.md b/docs/user/guide.md index eedd5a7..4be2d5a 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -225,8 +225,8 @@ its public wallet data with the separate wallet record. 5. Select and confirm that wallet. If it is locked, follow the displayed Bitcoin-Qt Console instructions; codex32 waits and continues automatically. - It imports the private descriptors, verifies the public set, and relocks an - encrypted wallet. + It gives Core the master private key, asks Core to create the standard + account-0 descriptors, scans history, and relocks an encrypted wallet. 6. If you need an online watch-only counterpart, keep the restored signer offline and follow Bitcoin Core v32's [offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md) diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index ffa1c12..4f7aa96 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -11,7 +11,7 @@ from codex32._bip32 import _master_xprv_from_seed from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import _descriptor_records, core_descriptors +from codex32.wallet import _descriptor_records class BitcoinCoreError(Exception): @@ -29,6 +29,7 @@ class BitcoinCoreError(Exception): _ORIGIN = re.compile(r"\[(?P[0-9a-f]{8})(?P(?:/[0-9]+[h']?)*)\]") _PRIVATE_MARKERS = ("xprv", "tprv") _PURPOSES = (44, 49, 84, 86) +_OUTPUT_TYPES = ("legacy", "p2sh-segwit", "bech32", "bech32m") @dataclass(frozen=True) @@ -244,6 +245,28 @@ def _target(self, name: str) -> tuple[bool, bool] | None: unlocked = info.get("unlocked_until") return (unlocked is not None, unlocked == 0) if eligible else None + def _create_account_zero(self, secret: MasterSeed, wallet: str) -> None: + root = _master_xprv_from_seed(secret.seed_bytes, testnet=self.chain != "main") + added = self._rpc("addhdkey", wallet=wallet, stdin=root + "\n") + xpub = added.get("xpub") if isinstance(added, dict) else None + if not isinstance(xpub, str) or not xpub.startswith("xpub" if self.chain == "main" else "tpub"): + raise BitcoinCoreError("Bitcoin Core did not accept the master HD key.") + options = json.dumps({"hdkey": xpub}, separators=(",", ":")) + for output_type in _OUTPUT_TYPES: + created = self._rpc( + "-named", "createwalletdescriptor", f"type={output_type}", f"options={options}", wallet=wallet + ) + descriptors = created.get("descs") if isinstance(created, dict) else None + if ( + not isinstance(descriptors, list) + or len(descriptors) != 2 + or not all( + isinstance(desc, str) and not any(key in desc for key in _PRIVATE_MARKERS) + for desc in descriptors + ) + ): + raise BitcoinCoreError("Bitcoin Core did not create both wallet descriptors.") + def _select( self, ask: Callable[[str], str], @@ -302,6 +325,12 @@ def initialize( account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: + if not isinstance(secret, MasterSeed): + raise TypeError("wallet operations accept only MasterSeed") + if type(account) is not int or account != 0: + raise ValueError("Bitcoin Core wallet initialization currently supports only account 0") + if timestamp != "now" and (type(timestamp) is not int or timestamp < 0): + raise ValueError("timestamp must be a nonnegative integer or 'now'") while True: name = self._select(ask, tell) state = self._target(name) @@ -339,60 +368,9 @@ def initialize( if state is None: tell("That wallet is no longer eligible. Choose again.") continue - records = core_descriptors( - secret, - account=account, - testnet=self.chain != "main", - private=True, - timestamp=timestamp, - ) - imported = self._rpc( - "importdescriptors", - wallet=name, - stdin=json.dumps(records, separators=(",", ":")) + "\n", - ) - del records - valid = ( - isinstance(imported, list) - and len(imported) == 4 - and all(isinstance(item, dict) and item.get("success") is True for item in imported) - ) - if not valid: - raise BitcoinCoreError("Bitcoin Core did not import every private descriptor.") - public = self.public_descriptors( - secret, - wallet=name, - account=account, - timestamp=timestamp, - ) - expected: list[tuple[str, bool, bool]] = [] - for record in public: - detail = self._rpc("getdescriptorinfo", stdin=str(record["desc"]) + "\n") - expansion = detail.get("multipath_expansion") if isinstance(detail, dict) else None - if ( - not isinstance(expansion, list) - or len(expansion) != 2 - or not all(isinstance(descriptor, str) for descriptor in expansion) - ): - raise BitcoinCoreError("Bitcoin Core did not expand the expected public descriptors.") - expected.extend( - (descriptor, True, bool(position)) for position, descriptor in enumerate(expansion) - ) - listed = self._rpc("listdescriptors", wallet=name) - values = listed.get("descriptors") if isinstance(listed, dict) else None - if not isinstance(values, list) or not all(isinstance(item, dict) for item in values): - raise BitcoinCoreError("Bitcoin Core did not return the imported public descriptors.") - actual = [ - ( - str(item.get("desc")), - item.get("active") is True, - item.get("internal") is True, - ) - for item in values - if item.get("active") is True - ] - if sorted(actual) != sorted(expected): - raise BitcoinCoreError("Bitcoin Core's accepted public descriptors did not match.") + self._create_account_zero(secret, name) + if timestamp != "now": + self._rpc("rescanblockchain", "0", wallet=name, timeout=86400) finally: warning = "Confirm immediately in Bitcoin Core that the wallet is locked." while relock: diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index fe273a4..1c4619f 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -66,9 +66,9 @@ def _terminal_output(parser: argparse.ArgumentParser) -> None: def _wallet_options(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--account", - type=_integer("account", 0, 2**31 - 1), + type=_integer("account", 0, 0), default=0, - help="account number (default: 0)", + help="account number (currently only 0)", ) parser.add_argument( "--timestamp", diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 506ad09..585a6dc 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -588,7 +588,11 @@ def _correct( def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int: if not sys.stdin.isatty(): raise _UsageError("Bitcoin Core wallet initialization requires an interactive terminal.") - _print("Warning: This imports private descriptors that can spend funds.", err=True, danger=True) + _print( + "Warning: This gives Bitcoin Core the master private key, which can spend funds.", + err=True, + danger=True, + ) core = _connected_core() secret = _master_seed(core.fingerprint) return _initialize_wallet( diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 7ea5c05..04ad61f 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -306,6 +306,7 @@ class _ImportRPC: encrypted: bool = True locked: bool = True imported: bool = False + created: int = 0 calls: list[tuple[tuple[str, ...], str | None, str | None]] = field(default_factory=list) expansions: list[str] = field(default_factory=list) @@ -315,7 +316,9 @@ def __call__( *arguments: str, wallet: str | None = None, stdin: str | None = None, + timeout: int = 120, ) -> object: + del timeout self.calls.append((arguments, wallet, stdin)) command = arguments[0] if command == "listwallets": @@ -331,44 +334,39 @@ def __call__( for position, descriptor in enumerate(self.expansions) ] return {"wallet_name": "signer", "descriptors": records} - if command == "gethdkeys": - assert wallet == "signer" and self.imported - return [{"xpub": _ROOT_XPUB, "has_private": True, "descriptors": []}] - if arguments[:2] == ("-named", "derivehdkey"): - assert wallet == "signer" - path = next(value.removeprefix("path=m") for value in arguments[2:] if value.startswith("path=")) - hdkey = next( - value.removeprefix("hdkey=") for value in arguments[2:] if value.startswith("hdkey=") - ) - assert hdkey == _ROOT_XPUB - purpose = int(path.split("h/", 1)[0].removeprefix("/")) - return {"origin": f"[3f3521a6{path}]", "xpub": _ACCOUNT_XPUBS[purpose]} + if command == "addhdkey": + assert wallet == "signer" and stdin is not None and stdin.startswith("xprv") + return {"xpub": _ROOT_XPUB} + if arguments[:2] == ("-named", "createwalletdescriptor"): + assert wallet == "signer" and stdin is None + assert json.loads(arguments[3].removeprefix("options=")) == {"hdkey": _ROOT_XPUB} + output_type = arguments[2].removeprefix("type=") + self.created += 1 + self.imported = True + self.expansions.extend((f"{output_type}-receive", f"{output_type}-change")) + return {"descs": self.expansions[-2:]} if command == "getdescriptorinfo": assert stdin is not None return _descriptor_info(stdin) - if command == "importdescriptors": - assert stdin is not None - records = json.loads(stdin) - self.expansions = [] - for record in records: - result = _descriptor_info(record["desc"]) - self.expansions.extend(result["multipath_expansion"]) - self.imported = True - return [{"success": True} for _ in range(4)] + if command == "rescanblockchain": + assert arguments == ("rescanblockchain", "0") and self.created == 4 + return {"start_height": 0, "stop_height": 100} if command == "walletlock": self.locked = True return None raise AssertionError(command) -def test_encrypted_import_retries_without_a_passphrase_verifies_and_relocks( +def test_encrypted_wallet_uses_core_descriptors_and_relocks( monkeypatch: pytest.MonkeyPatch, ) -> None: rpc = _ImportRPC() monkeypatch.setattr( BitcoinCore, "_rpc", - lambda client, *args, wallet=None, stdin=None: rpc(client, *args, wallet=wallet, stdin=stdin), + lambda client, *args, wallet=None, stdin=None, timeout=120: rpc( + client, *args, wallet=wallet, stdin=stdin, timeout=timeout + ), ) client = BitcoinCore("bitcoin-cli", "main", 300000) messages: list[str] = [] @@ -384,13 +382,14 @@ def unlock(seconds: int) -> None: private_calls = [call for call in rpc.calls if "xprv" in (call[2] or "")] assert len(private_calls) == 1 arguments, wallet, private_stdin = private_calls[0] - assert arguments == ("importdescriptors",) and wallet == "signer" + assert arguments == ("addhdkey",) and wallet == "signer" assert private_stdin is not None and private_stdin.endswith("\n") - records = json.loads(private_stdin) - assert len(records) == 4 and all(record["timestamp"] == "now" for record in records) - assert all("xprv" in record["desc"] for record in records) + assert private_stdin.startswith("xprv") assert all("xprv" not in " ".join((*args, selected or "")) for args, selected, _data in rpc.calls) - assert sum(args[:2] == ("-named", "derivehdkey") for args, _wallet, _stdin in rpc.calls) == 4 + assert [ + args[2] for args, _wallet, _stdin in rpc.calls if args[:2] == ("-named", "createwalletdescriptor") + ] == ["type=legacy", "type=p2sh-segwit", "type=bech32", "type=bech32m"] + assert not any(args == ("rescanblockchain", "0") for args, _wallet, _stdin in rpc.calls) assert rpc.locked assert delays == [1] assert ( @@ -403,7 +402,18 @@ def unlock(seconds: int) -> None: assert messages[-1] == "" -def test_failed_import_is_generic_and_relocks_encrypted_wallet( +@pytest.mark.parametrize("account", (1, 7, True)) +def test_nonzero_or_noninteger_account_is_rejected_before_wallet_selection( + account: int, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(BitcoinCore, "_select", lambda *_args: pytest.fail("selected a wallet")) + with pytest.raises(ValueError, match="only account 0"): + BitcoinCore("bitcoin-cli", "main", 320000).initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, account=account + ) + + +def test_failed_descriptor_creation_relocks_encrypted_wallet( monkeypatch: pytest.MonkeyPatch, ) -> None: rpc = _ImportRPC(locked=False) @@ -412,14 +422,14 @@ def test_failed_import_is_generic_and_relocks_encrypted_wallet( def fail( client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None ) -> object: - if arguments == ("importdescriptors",): + if arguments[:3] == ("-named", "createwalletdescriptor", "type=bech32"): rpc.calls.append((arguments, wallet, stdin)) - return [{"success": True}, {"success": False}, {"success": True}, {"success": True}] + return {"descs": []} return original(client, *arguments, wallet=wallet, stdin=stdin) monkeypatch.setattr(BitcoinCore, "_rpc", fail) client = BitcoinCore("bitcoin-cli", "main", 300000) - with pytest.raises(BitcoinCoreError, match="did not import every private descriptor"): + with pytest.raises(BitcoinCoreError, match="did not create both wallet descriptors"): client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) assert rpc.locked assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -454,36 +464,23 @@ def rpc( ] -@pytest.mark.parametrize("change", ("missing", "extra")) -def test_exact_public_descriptor_verification_rejects_missing_or_extra_records( - change: str, - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_numeric_timestamp_rescans_history(monkeypatch: pytest.MonkeyPatch) -> None: rpc = _ImportRPC(locked=False) - original = rpc.__call__ - - def alter( - client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None - ) -> object: - result = original(client, *arguments, wallet=wallet, stdin=stdin) - if arguments == ("listdescriptors",) and rpc.imported and isinstance(result, dict): - descriptors = result["descriptors"] - assert isinstance(descriptors, list) - if change == "missing": - descriptors.pop(0) - else: - descriptors.append({"desc": "unexpected", "active": True, "internal": False}) - return result - - monkeypatch.setattr(BitcoinCore, "_rpc", alter) + monkeypatch.setattr( + BitcoinCore, + "_rpc", + lambda client, *args, wallet=None, stdin=None, timeout=120: rpc( + client, *args, wallet=wallet, stdin=stdin, timeout=timeout + ), + ) client = BitcoinCore("bitcoin-cli", "main", 300000) - with pytest.raises(BitcoinCoreError, match="accepted public descriptors did not match"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + assert client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None, timestamp=123) == "signer" + assert any(args == ("rescanblockchain", "0") for args, _wallet, _stdin in rpc.calls) assert rpc.locked -@pytest.mark.parametrize("command", ("getdescriptorinfo", "listdescriptors")) -def test_public_preparation_and_verification_failures_relock( +@pytest.mark.parametrize("command", ("addhdkey", "createwalletdescriptor")) +def test_core_creation_failures_relock( command: str, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -493,7 +490,9 @@ def test_public_preparation_and_verification_failures_relock( def fail( client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None ) -> object: - if arguments == (command,) and (command == "getdescriptorinfo" or rpc.imported): + if arguments[0] == command or ( + command == "createwalletdescriptor" and arguments[:2] == ("-named", command) + ): raise BitcoinCoreError("suppressed failure") return original(client, *arguments, wallet=wallet, stdin=stdin) @@ -512,7 +511,7 @@ def test_interruption_after_unlock_relocks(monkeypatch: pytest.MonkeyPatch) -> N def interrupt( client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None ) -> object: - if arguments == ("getdescriptorinfo",): + if arguments == ("addhdkey",): raise KeyboardInterrupt return original(client, *arguments, wallet=wallet, stdin=stdin) @@ -603,8 +602,11 @@ def target(*_args: object, **_options: object) -> tuple[bool, bool]: == "signer" ) assert delays == [1] - assert sum(arguments == ("getdescriptorinfo",) for arguments, _wallet, _stdin in rpc.calls) == 8 - assert sum(arguments == ("importdescriptors",) for arguments, _wallet, _stdin in rpc.calls) == 1 + assert ( + sum(arguments[:2] == ("-named", "createwalletdescriptor") for arguments, _wallet, _stdin in rpc.calls) + == 4 + ) + assert sum(arguments == ("addhdkey",) for arguments, _wallet, _stdin in rpc.calls) == 1 def test_interruption_during_walletlock_retries_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: @@ -633,7 +635,9 @@ def test_unencrypted_wallet_imports_without_a_lock_call(monkeypatch: pytest.Monk monkeypatch.setattr( BitcoinCore, "_rpc", - lambda client, *args, wallet=None, stdin=None: rpc(client, *args, wallet=wallet, stdin=stdin), + lambda client, *args, wallet=None, stdin=None, timeout=120: rpc( + client, *args, wallet=wallet, stdin=stdin, timeout=timeout + ), ) client = BitcoinCore("bitcoin-cli", "main", 300000) messages: list[str] = [] @@ -664,7 +668,7 @@ def test_immediate_revalidation_stops_before_private_import_and_relocks( with pytest.raises(BitcoinCoreError, match="changed before import"): client.initialize(_SEED, lambda _prompt: "", lambda _message: None) assert rpc.locked - assert not any(arguments == ("importdescriptors",) for arguments, _wallet, _stdin in rpc.calls) + assert not any(arguments == ("addhdkey",) for arguments, _wallet, _stdin in rpc.calls) def test_subprocess_adapter_uses_loopback_and_never_repeats_raw_core_errors( @@ -675,7 +679,7 @@ def test_subprocess_adapter_uses_loopback_and_never_repeats_raw_core_errors( def run(command: list[str], **options: object) -> subprocess.CompletedProcess[str]: assert marker not in command assert command[1:3] == ["-chain=main", "-rpcconnect=127.0.0.1"] - assert command[-2:] == ["-stdin", "importdescriptors"] + assert command[-2:] == ["-stdin", "addhdkey"] assert options["input"] == marker + "\n" return subprocess.CompletedProcess(command, 1, marker, marker) @@ -683,5 +687,5 @@ def run(command: list[str], **options: object) -> subprocess.CompletedProcess[st client = BitcoinCore("/reviewed/bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError) as failure: - client._rpc("importdescriptors", wallet="wallet", stdin=marker + "\n") + client._rpc("addhdkey", wallet="wallet", stdin=marker + "\n") assert marker not in str(failure.value) diff --git a/tests/test_cli.py b/tests/test_cli.py index 441d19e..b9fa03b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1876,7 +1876,7 @@ def test_wallet_commands_initialize_selected_master_seed_destinations() -> None: assert private.stdout == "" assert private_core.imported == parse_codex32(VECTOR_1["secret_s"]) assert private_core.private is True - assert "Warning: This imports private descriptors that can spend funds." in private.stderr + assert "Warning: This gives Bitcoin Core the master private key, which can spend funds." in private.stderr assert "Use only the intended encrypted wallet" not in private.stderr assert "\x1b[" not in private.stderr + private.stdout assert "spending wallet initialized" in private.stderr @@ -1892,6 +1892,12 @@ def test_bitcoin_core_cli_accepts_now_timestamp() -> None: assert core.timestamp == "now" +def test_bitcoin_core_cli_rejects_other_accounts() -> None: + result = _invoke(["wallet", "--account", "7"]) + assert result.exit_code == 2 + assert "account" in result.stderr + + def test_bitcoin_core_cli_derives_test_network_from_connected_core() -> None: result, core = _invoke_initialized_wallet( ["wallet"], @@ -1944,7 +1950,10 @@ def test_wallet_private_warning_precedes_recovery_input( cli_module = importlib.import_module("codex32.cli") def stop_before_input(_fingerprint=None) -> MasterSeed: - assert "Warning: This imports private descriptors that can spend funds." in capsys.readouterr().err + assert ( + "Warning: This gives Bitcoin Core the master private key, which can spend funds." + in capsys.readouterr().err + ) raise cli_module._UsageError("stopped") monkeypatch.setattr(cli_module, "_master_seed", stop_before_input) diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index c4dc3c4..fb82bd7 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -158,7 +158,7 @@ def test_cli_split_and_unknown_neutral_summary() -> None: "Restore a Bitcoin Core wallet.\n\n" "options:\n" " -h, --help show this help message and exit\n" - " --account ACCOUNT account number (default: 0)\n" + " --account ACCOUNT account number (currently only 0)\n" " --timestamp TIMESTAMP\n" " search for transactions since this Unix timestamp; use\n" " 0 for all history or now for a new wallet\n" diff --git a/tools/bitcoin_core_main_smoke.py b/tools/bitcoin_core_main_smoke.py index 5ce027b..34775fd 100644 --- a/tools/bitcoin_core_main_smoke.py +++ b/tools/bitcoin_core_main_smoke.py @@ -131,34 +131,12 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None raise RuntimeError("automatic initialization did not relock the wallet") _verify_origins(rpc("listdescriptors", wallet="signer"), account=0) - rpc( - "-named", - "createwallet", - "wallet_name=account7", - "disable_private_keys=false", - "blank=true", - "descriptors=true", - ) - account_answers = iter(("yes",)) - if ( - client.initialize( - secret, - lambda _prompt: next(account_answers), - lambda _message: None, - account=7, - timestamp="now", - ) - != "account7" - ): - raise RuntimeError("account-7 initialization selected the wrong wallet") - _verify_origins(rpc("listdescriptors", wallet="account7"), account=7) - print( json.dumps( { "bitcoin_core": network["subversion"], "chain": blockchain["chain"], - "account": 7, + "account": 0, "status": "pass", } ) diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index d9a70d2..7f7ec3e 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -187,33 +187,6 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if rpc("gettransaction", spend, wallet="restore")["confirmations"] < 1: raise RuntimeError("recovered wallet did not sign and broadcast") - rpc( - "-named", - "createwallet", - "wallet_name=account7", - "disable_private_keys=false", - "blank=true", - "descriptors=true", - ) - account_answers = iter(("yes",)) - if ( - client.initialize( - secret, - lambda _prompt: next(account_answers), - lambda _message: None, - account=7, - timestamp="now", - ) - != "account7" - ): - raise RuntimeError("account-7 initialization selected the wrong wallet") - account_active = [ - item for item in rpc("listdescriptors", wallet="account7")["descriptors"] if item["active"] - ] - for purpose in (44, 49, 84, 86): - if sum(f"/{purpose}h/1h/7h]" in item["desc"] for item in account_active) != 2: - raise RuntimeError(f"Core did not create the expected BIP{purpose} account-7 origins") - main_private = core_descriptors(secret, private=True, timestamp=0) test_private = core_descriptors(secret, testnet=True, private=True, timestamp=0) if "xprv" not in json.dumps(main_private) or "tprv" not in json.dumps(test_private): From 73b15380e5a0779c312942d1a05c9e559e7a59f3 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 13:28:11 -0500 Subject: [PATCH 3/4] wallet: Honor Unix rescan timestamps Core's createwalletdescriptor has no timestamp parameter. Reimport one newly created active descriptor with its existing range and next index through bitcoin-cli stdin, letting Core apply its time window to a wallet-wide scan without guessing a block height. Keep private material out of arguments and relock on failure. Cover genesis and nonzero timestamps with unit tests and a two-era Core v32 regtest that skips older outputs while recovering recent ones. --- docs/developer/api.md | 12 ++++--- docs/security/model.md | 4 +-- docs/user/guide.md | 3 ++ src/codex32/_bitcoin_core.py | 55 +++++++++++++++++++++++++++++++- src/codex32/_cli_parser.py | 2 +- tests/test_bitcoin_core.py | 59 ++++++++++++++++++++++++++++++----- tests/test_generic_hrp.py | 4 +-- tools/bitcoin_core_regtest.py | 36 ++++++++++++++++++++- 8 files changed, 156 insertions(+), 19 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 1a8286b..b0ddf3b 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -602,10 +602,10 @@ API inputs. The `ms32 wallet` CLI takes `--account 0` and `--timestamp`; the selected Bitcoin Core chain is authoritative and there is no wallet `--testnet` flag. `ms32 xprv --testnet` remains explicit because it directly selects xprv versus tprv serialization. The timestamp defaults to `0` so -recovery scans from genesis. Any nonnegative Unix time currently performs a -conservative full rescan; `now` skips historical discovery. Nonzero wallet -accounts await upstream Core support. There is no account database, descriptor -parser, policy language, RPC library, or network client. +recovery scans from genesis. A nonzero Unix time uses Core's timestamped +rescan with its two-hour safety window; `now` skips historical discovery. +Nonzero wallet accounts await upstream Core support. There is no account +database, descriptor parser, policy language, RPC library, or network client. Bitcoin master-seed creation and restoration use a private CLI adapter. Before entropy or recovery input it resolves `bitcoin-cli` from `PATH` and probes the @@ -627,7 +627,9 @@ and no passphrase interface exists. Core v32 accepts the key with `addhdkey` and creates external and internal account-0 descriptors for BIP44/49/84/86 with `createwalletdescriptor`. Python checks each call's result but trusts Core to derive and store the wallet policy. -Numeric recovery timestamps run `rescanblockchain` from genesis. The adapter +Numeric recovery timestamps re-import one existing active private descriptor +through stdin with its range and next index preserved; Core then rescans the +whole wallet from the supplied time (or genesis for `0`). The adapter relocks wallets Core reports as encrypted. Master-fingerprint display is likewise delegated to Core: a stateless root P2PKH descriptor is normalized, `deriveaddresses` derives its diff --git a/docs/security/model.md b/docs/security/model.md index ceb98db..7a7de88 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -231,8 +231,8 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | -| Secret channel | The master xprv is sent only through the child's standard input. It is absent from arguments, ordinary output, and diagnostics. codex32 has no passphrase channel and suppresses raw Core errors. | -| Revalidation | Every destination property is checked again immediately before adding the key. `addhdkey` must accept it and `createwalletdescriptor` must return two public descriptors for each requested address type. Numeric recovery timestamps trigger a full Core rescan. Core is trusted to derive and store the wallet policy. | +| Secret channel | The master xprv is sent only through the child's standard input. A timestamped rescan obtains one private descriptor from Core's captured stdout and returns it through stdin; neither value is printed or passed in arguments or diagnostics. codex32 has no passphrase channel and suppresses raw Core errors. | +| Revalidation | Every destination property is checked again immediately before adding the key. `addhdkey` must accept it and `createwalletdescriptor` must return two public descriptors for each requested address type. Numeric recovery timestamps trigger Core's time-based rescan (genesis for `0`), with no guessed block height. Core is trusted to derive and store the wallet policy. | | Relocking | Once Core reports an encrypted private-key wallet unlocked, a `finally`-protected obligation requests `walletlock` and verifies the locked state after success, failure, state change, or interruption. | The unlock command is entered in Bitcoin-Qt. Its diff --git a/docs/user/guide.md b/docs/user/guide.md index 4be2d5a..7be6bcf 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -223,6 +223,9 @@ its public wallet data with the separate wallet record. ms32 wallet --timestamp 0 ``` + If you know when the wallet was first used, an earlier Unix timestamp can + shorten the rescan; `0` remains the safest choice when unsure. + 5. Select and confirm that wallet. If it is locked, follow the displayed Bitcoin-Qt Console instructions; codex32 waits and continues automatically. It gives Core the master private key, asks Core to create the standard diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 4f7aa96..cfec702 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -267,6 +267,59 @@ def _create_account_zero(self, secret: MasterSeed, wallet: str) -> None: ): raise BitcoinCoreError("Bitcoin Core did not create both wallet descriptors.") + def _rescan_from_timestamp(self, wallet: str, timestamp: int) -> None: + # createwalletdescriptor has no timestamp option. Re-import one of its + # existing active descriptors so Core applies its own two-hour time + # window and scans the whole wallet, without guessing a block height. + listing = self._rpc("listdescriptors", "true", wallet=wallet) + records = listing.get("descriptors") if isinstance(listing, dict) else None + if not isinstance(records, list): + raise BitcoinCoreError("Bitcoin Core did not list the wallet descriptors.") + candidates = [ + record + for record in records + if isinstance(record, dict) and record.get("active") is True and record.get("internal") is False + ] + if len(candidates) != len(_OUTPUT_TYPES): + raise BitcoinCoreError("Bitcoin Core did not return the expected receiving descriptors.") + descriptor = candidates[0] + private = descriptor.get("desc") + span = descriptor.get("range") + next_index = descriptor.get("next_index") + if ( + not isinstance(private, str) + or not any(marker in private for marker in _PRIVATE_MARKERS) + or not isinstance(span, list) + or len(span) != 2 + or any(type(bound) is not int for bound in span) + or type(next_index) is not int + or not span[0] <= next_index <= span[1] + ): + raise BitcoinCoreError("Bitcoin Core returned an unexpected private descriptor.") + request = [ + { + "desc": private, + "timestamp": timestamp, + "active": True, + "internal": False, + "range": span, + "next_index": next_index, + } + ] + result = self._rpc( + "importdescriptors", + wallet=wallet, + stdin=json.dumps(request, separators=(",", ":")) + "\n", + timeout=86400, + ) + if ( + not isinstance(result, list) + or len(result) != 1 + or not isinstance(result[0], dict) + or result[0].get("success") is not True + ): + raise BitcoinCoreError("Bitcoin Core did not complete the timestamped wallet rescan.") + def _select( self, ask: Callable[[str], str], @@ -370,7 +423,7 @@ def initialize( continue self._create_account_zero(secret, name) if timestamp != "now": - self._rpc("rescanblockchain", "0", wallet=name, timeout=86400) + self._rescan_from_timestamp(name, timestamp) finally: warning = "Confirm immediately in Bitcoin Core that the wallet is locked." while relock: diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index 1c4619f..c247210 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -74,7 +74,7 @@ def _wallet_options(parser: argparse.ArgumentParser) -> None: "--timestamp", type=_timestamp, default=0, - help="search for transactions since this Unix timestamp; use 0 for all history or now for a new wallet", + help="rescan from a Unix time at/before first use; use 0 for all history or now for a new wallet", ) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 04ad61f..72d6676 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -307,6 +307,7 @@ class _ImportRPC: locked: bool = True imported: bool = False created: int = 0 + rescan_success: bool = True calls: list[tuple[tuple[str, ...], str | None, str | None]] = field(default_factory=list) expansions: list[str] = field(default_factory=list) @@ -330,7 +331,13 @@ def __call__( if not self.imported: return {"wallet_name": "signer", "descriptors": []} records = [ - {"desc": descriptor, "active": True, "internal": bool(position % 2)} + { + "desc": f"{descriptor}-xprv" if arguments[1:] == ("true",) else descriptor, + "active": True, + "internal": bool(position % 2), + "range": [0, 999], + "next_index": 0, + } for position, descriptor in enumerate(self.expansions) ] return {"wallet_name": "signer", "descriptors": records} @@ -348,9 +355,10 @@ def __call__( if command == "getdescriptorinfo": assert stdin is not None return _descriptor_info(stdin) - if command == "rescanblockchain": - assert arguments == ("rescanblockchain", "0") and self.created == 4 - return {"start_height": 0, "stop_height": 100} + if command == "importdescriptors": + assert arguments == ("importdescriptors",) and wallet == "signer" and self.created == 4 + assert stdin is not None + return [{"success": self.rescan_success}] if command == "walletlock": self.locked = True return None @@ -390,6 +398,7 @@ def unlock(seconds: int) -> None: args[2] for args, _wallet, _stdin in rpc.calls if args[:2] == ("-named", "createwalletdescriptor") ] == ["type=legacy", "type=p2sh-segwit", "type=bech32", "type=bech32m"] assert not any(args == ("rescanblockchain", "0") for args, _wallet, _stdin in rpc.calls) + assert not any(args == ("importdescriptors",) for args, _wallet, _stdin in rpc.calls) assert rpc.locked assert delays == [1] assert ( @@ -464,7 +473,8 @@ def rpc( ] -def test_numeric_timestamp_rescans_history(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("timestamp", (0, 123)) +def test_numeric_timestamp_rescans_history(monkeypatch: pytest.MonkeyPatch, timestamp: int) -> None: rpc = _ImportRPC(locked=False) monkeypatch.setattr( BitcoinCore, @@ -474,8 +484,43 @@ def test_numeric_timestamp_rescans_history(monkeypatch: pytest.MonkeyPatch) -> N ), ) client = BitcoinCore("bitcoin-cli", "main", 300000) - assert client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None, timestamp=123) == "signer" - assert any(args == ("rescanblockchain", "0") for args, _wallet, _stdin in rpc.calls) + assert ( + client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None, timestamp=timestamp) + == "signer" + ) + calls = [(args, data) for args, _wallet, data in rpc.calls if args == ("importdescriptors",)] + assert len(calls) == 1 + args, data = calls[0] + assert args == ("importdescriptors",) + assert data is not None + assert json.loads(data) == [ + { + "desc": "legacy-receive-xprv", + "timestamp": timestamp, + "active": True, + "internal": False, + "range": [0, 999], + "next_index": 0, + } + ] + assert not any(args[0] == "rescanblockchain" for args, _wallet, _data in rpc.calls) + assert all("xprv" not in " ".join(args) for args, _wallet, _data in rpc.calls) + assert rpc.locked + + +def test_failed_timestamped_rescan_relocks(monkeypatch: pytest.MonkeyPatch) -> None: + rpc = _ImportRPC(locked=False, rescan_success=False) + monkeypatch.setattr( + BitcoinCore, + "_rpc", + lambda client, *args, wallet=None, stdin=None, timeout=120: rpc( + client, *args, wallet=wallet, stdin=stdin, timeout=timeout + ), + ) + with pytest.raises(BitcoinCoreError, match="did not complete the timestamped wallet rescan"): + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, timestamp=123 + ) assert rpc.locked diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index fb82bd7..9111a7d 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -160,8 +160,8 @@ def test_cli_split_and_unknown_neutral_summary() -> None: " -h, --help show this help message and exit\n" " --account ACCOUNT account number (currently only 0)\n" " --timestamp TIMESTAMP\n" - " search for transactions since this Unix timestamp; use\n" - " 0 for all history or now for a new wallet\n" + " rescan from a Unix time at/before first use; use 0 for\n" + " all history or now for a new wallet\n" ) assert _invoke(main, ["--version"])[1].startswith("codex32 ") assert _invoke(ms_main, ["--version"])[1].startswith("ms32 ") diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 7f7ec3e..e7c8e3e 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -9,6 +9,7 @@ import shutil import subprocess import tempfile +import time from pathlib import Path from typing import Any @@ -86,6 +87,9 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if not isinstance(network, dict) or network.get("version", 0) < 320000: raise RuntimeError("Bitcoin Core 32 or newer is required") + current_time = int(time.time()) + rpc("setmocktime", str(current_time - 6 * 3600)) + rpc( "-named", "createwallet", @@ -155,6 +159,9 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None signer_address = rpc("getnewaddress", wallet="signer") rpc("sendtoaddress", signer_address, "1", wallet="miner") rpc("generatetoaddress", "1", miner_address) + rpc("setmocktime", str(current_time)) + rpc("sendtoaddress", signer_address, "0.5", wallet="miner") + rpc("generatetoaddress", "1", miner_address) rpc( "-named", @@ -179,9 +186,36 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None recovered_address = rpc("getaddressinfo", signer_address, wallet="restore") if not isinstance(recovered_address, dict) or recovered_address.get("ismine") is not True: raise RuntimeError("recovered wallet did not recognize the funded signer address") - if rpc("getbalance", wallet="restore") != 1: + if rpc("getbalance", wallet="restore") != 1.5: raise RuntimeError("recovery rescan did not find the funded output") + rpc( + "-named", + "createwallet", + "wallet_name=restore_recent", + "disable_private_keys=false", + "blank=true", + "descriptors=true", + ) + recent_timestamp = current_time - 1800 + if ( + client.initialize( + secret, + lambda _prompt: "yes", + lambda _message: None, + account=0, + timestamp=recent_timestamp, + ) + != "restore_recent" + ): + raise RuntimeError("timestamped initialization selected the wrong wallet") + if rpc("getbalance", wallet="restore_recent") != 0.5: + raise RuntimeError("timestamped recovery missed a recent output or scanned older history") + recent_records = rpc("listdescriptors", wallet="restore_recent")["descriptors"] + if sum(record["timestamp"] == recent_timestamp for record in recent_records) != 1: + raise RuntimeError("Core did not retain the timestamped descriptor") + rpc("getnewaddress", "", "legacy", wallet="restore_recent") + spend = rpc("sendtoaddress", miner_address, "0.5", wallet="restore") rpc("generatetoaddress", "1", miner_address) if rpc("gettransaction", spend, wallet="restore")["confirmations"] < 1: From f74e5a0af9f168799edc6251033b2888fe0dea3d Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 13:36:13 -0500 Subject: [PATCH 4/4] docs: Correct Core rescan RPC list --- docs/developer/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index b0ddf3b..4a290d8 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -639,7 +639,7 @@ the BIP32 fingerprint. The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`, `getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`, `validateaddress`, `gethdkeys`, `derivehdkey`, `addhdkey`, -`createwalletdescriptor`, `rescanblockchain`, and `walletlock`. +`createwalletdescriptor`, `importdescriptors`, and `walletlock`. Bitcoin Core alone creates wallets, selects encryption, handles passphrases, stores keys, and provides normal wallet behavior.