diff --git a/AGENTS.md b/AGENTS.md index cc23028..7fb0f33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ avoid comments or tests that restate the implementation. Add or update concise docstrings when changing public behavior. Write codex32 in lowercase except when referring to the Codex32 Book. -Keep the installed package below 5,000 logical review lines, as enforced by the +Keep the installed package below 5,025 logical review lines, as enforced by the existing test. New dependencies, public API signature or return-shape changes, and lint suppressions require user authorization; an explicit request can already provide that authorization. diff --git a/docs/developer/api.md b/docs/developer/api.md index 1b829ef..0f2ef01 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -102,7 +102,7 @@ unsupported but remains in the review scope. ### Size budget -V1 keeps the installed package below 5,000 logical review lines, excluding +V1 keeps the installed package below 5,025 logical review lines, excluding blank and comment-only lines while counting subpackages recursively. Changing the budget requires explicit review and authorization together with the matching documentation and enforcement update. @@ -114,7 +114,7 @@ installed as `codex32[gui]` and started by `codex32-gui`. It is a client of the surface above and of the private Core adapter; nothing in `src/codex32/` imports it, and the base install keeps its property of having no third-party runtime dependency. It carries its own budget of 2,050 logical review lines, separate -from the 5,000 above. Its own boundaries are documented in +from the 5,025 above. Its own boundaries are documented in [`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`. ## Profile and opaque-HRP capabilities @@ -200,8 +200,9 @@ emitted and confirmed unchanged, and the original validated artifact initializes the wallet. This neutral source prompt tries raw hexadecimal first and otherwise requires a complete explicit `ms1` string; it never infers or corrects a missing HRP or separator. No entropy is drawn for this path; raw hexadecimal seeds retain -the generation path. Existing imports use timestamp zero to include prior -history. Changing a supplied secret's identifier requires a sharing threshold. +the generation path. Existing imports ask when the seed was first used and +rescan from a day before that date; a blank answer uses timestamp zero to +include all prior history. Changing a supplied secret's identifier requires a sharing threshold. Shared creation uses an explicit threshold or full backup header. Without an explicit share count or indices, thresholds 2 and 3 produce the reviewed 2-of-3 and 3-of-5 @@ -652,7 +653,18 @@ 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`, +Immediately before `importdescriptors`, `check_history` refuses a dated rescan +Core could not finish: a start within a day of the pruned node's oldest kept +block (`getblockstats`), where a future timestamp counts as the tip's median +time because Core scans from there, or any dated import while `getchainstates` +shows an AssumeUTXO snapshot still being validated in the background. Unclear +answers are refused too. A `"now"` import is never refused: a fresh seed has no +history, and Core shows when its wallets are still catching up. The GUI runs +the same check before `createwallet` for a restore, and `ms32 create +--existing` asks when the seed was first used instead of rescanning from 0. + +The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, +`getblockstats`, `getchainstates`, `listwallets`, `getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`, `validateaddress`, `importdescriptors`, `gethdkeys`, `derivehdkey`, and `walletlock`. Bitcoin Core alone creates wallets, selects encryption, handles diff --git a/docs/developer/gui.md b/docs/developer/gui.md index b3f7ce8..3bd624f 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -29,7 +29,7 @@ real widgets through every task under a throwaway X server and is the cheapest way to see the screens without a desktop. The package carries its own budget of 2,050 logical lines, separate from the -5,000 the installed library keeps, and `tests/test_gui_boundaries.py` enforces +5,025 the installed library keeps, and `tests/test_gui_boundaries.py` enforces it. The plan proposed 1,000 before the screens were written and the budget was 1,800 before the security review of 2026-09-19; that review's remediations are about 250 lines, and the rest of the difference is user-facing wording in @@ -102,8 +102,12 @@ Bitcoin Core's own 180-second timeout. `unlock` carries the same obligation over its own post-check. Both are exercised in `tests/test_gui_wallet_setup.py`. **Creating the blank wallet.** `createwallet` is issued with one fixed shape: -`wallet_name`, `disable_private_keys=false`, `blank=true`, and a `passphrase` -line only when one was given. No other option is ever sent. Bitcoin Core's +`wallet_name`, `disable_private_keys=false`, `blank=true`, +`load_on_startup=true`, and a `passphrase` line only when one was given. No +other option is ever sent. `load_on_startup` has Bitcoin Core record the wallet +in its own settings, so Core loads it at every start and a pruned node never +prunes past it; when Core warns that it could not save that setting, `create` +refuses the wallet. Bitcoin Core's `CreateWallet` guards its unlock branch with `if (!create_blank)`, so a blank encrypted wallet is created locked; the window already holds the passphrase, so it unlocks immediately and the separate unlock screen appears only for a wallet diff --git a/docs/security/invariants.md b/docs/security/invariants.md index ffd83ee..e8b59a5 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -42,8 +42,10 @@ and evidence. `codex32_gui/wallet_setup.py`, which is also the only module there that speaks to Bitcoin Core: it may send an operator-supplied passphrase to `bitcoin-cli` on standard input to unlock a wallet, may create one blank - descriptor wallet with a fixed set of arguments and no options, and may - request `walletlock`. It stores no passphrase and writes nothing to disk. + descriptor wallet with a fixed set of arguments and no options (one of them, + `load_on_startup=true`, has Bitcoin Core itself keep the wallet loaded at + every start), and may request `walletlock`. It stores no passphrase and + writes nothing to disk. An unlocked encrypted signer is relocked and verified on every exit path: by the library, unchanged, and additionally by a `finally`-protected obligation covering every wallet the graphical program itself unlocked. No window may diff --git a/docs/security/model.md b/docs/security/model.md index 4b11d95..1899d91 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -256,7 +256,7 @@ Core adapter. | Departure | Required behavior | |---|---| | Passphrase | The operator may supply a Bitcoin Core wallet passphrase. It reaches `bitcoin-cli` through `-stdinwalletpassphrase`, never through an argument, so it is absent from `/proc` and process listings. It is not stored, not logged, and not written to disk, and a passphrase containing a line break is refused rather than truncated. A passphrase this computer's locale would encode as something other than what Bitcoin-Qt sends is refused, so no half-encoded secret reaches a screen or a traceback. The screen keeps the command line's behavior as an alternative: the operator may unlock in Bitcoin-Qt instead, and the program then only rechecks wallet state. | -| Wallet creation | `createwallet` may be issued once, with `wallet_name`, `disable_private_keys=false`, `blank=true`, and a `passphrase` only when one was given. No other option is sent, and the resulting wallet must pass the same eligibility test as any other destination before it is used. Names are restricted to printable text without leading or trailing spaces, and may not contain a slash or be `.` or `..`, so a name can neither span the one-argument-per-line channel nor describe a path. | +| Wallet creation | `createwallet` may be issued once, with `wallet_name`, `disable_private_keys=false`, `blank=true`, `load_on_startup=true`, and a `passphrase` only when one was given. No other option is sent. `load_on_startup` makes Bitcoin Core, not codex32, record the wallet in its own settings so it is loaded at every start and a pruned node never prunes past it; if Core reports that it could not save that setting, the wallet is refused, and the resulting wallet must pass the same eligibility test as any other destination before it is used. Names are restricted to printable text without leading or trailing spaces, and may not contain a slash or be `.` or `..`, so a name can neither span the one-argument-per-line channel nor describe a path. | | Relocking | Every wallet this program unlocks carries a `finally`-protected obligation of its own, in `wallet_setup.fill`, that requests `walletlock` and verifies `unlocked_until` is zero. The library's obligation is armed only after it has chosen a wallet, so a refusal raised before that point would otherwise leave an unlocked wallet open until Bitcoin Core's own timeout. Worker threads are not daemons, so closing the window during an import runs both obligations rather than skipping them. | Destination selection is unchanged and is not delegated to prompt wording. The diff --git a/docs/user/gui.md b/docs/user/gui.md index d493275..f3a3c97 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -167,6 +167,19 @@ fingerprint and whether the backup identifier was made from the recovered seed, explains what that can and cannot prove, and restores only if you still choose to. Check the balance and history before you send money to that wallet. +The same page asks for the approximate creation date from your wallet record. +Bitcoin Core searches for the wallet's transactions from a day before that date, +so a pruned node, which keeps only recent blocks, can still find them. Leave it +blank to search all history. If the node has already pruned blocks the search +would need, the window says so before it changes anything in Bitcoin Core; a +date after the pruned blocks works, and otherwise the node must download the +blockchain again. A node started from an AssumeUTXO snapshot lacks older blocks +until it finishes checking them, so the window asks you to restore after that. +A new wallet has no history to search, so it is made and loaded at once; +Bitcoin Core itself shows that it is still catching up. +New wallets made here are loaded each time Bitcoin Core +starts, so a pruned node keeps them up to date instead of pruning past them. + After the restore, **check** the remaining wallet details against your record rather than copy them onto it. It shows no creation date on that screen, because the real one is already on your record and today's would replace it. diff --git a/docs/user/guide.md b/docs/user/guide.md index fee2ba0..a9da499 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -123,7 +123,10 @@ Already have a complete codex32 `ms` secret? Run `ms32 create --existing` to write and confirm its recovery card and initialize a Bitcoin Core wallet. The existing secret is preserved unchanged. To split it into three cards requiring any two, use `ms32 create 2 --existing` instead. Enter the secret -only when prompted. Bitcoin Core also scans for prior transactions. +only when prompted. codex32 then asks for the date the seed was first used: +Bitcoin Core scans for prior transactions from a day before it, so a pruned +node can still find them. Leave it blank to scan all history, or enter +today's date if the seed has never received bitcoin. ### 3. Make a Bitcoin Core wallet diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index f7cf279..320d9fb 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -1,11 +1,13 @@ from __future__ import annotations +import calendar import hashlib import json import re import shutil import string import subprocess +import time from collections.abc import Callable from dataclasses import dataclass from time import sleep @@ -26,12 +28,12 @@ class FingerprintMismatch(BitcoinCoreError): """The recovered seed is not the wallet the operator's record describes.""" -_CHAINS = ( - ("main", "mainnet"), - ("test", "testnet3"), - ("testnet4", "testnet4"), - ("signet", "signet"), - ("regtest", "regtest"), +_CHAINS = ( # Name, label, and genesis block time: no block on that chain is older. + ("main", "mainnet", 1231006505), + ("test", "testnet3", 1296688602), + ("testnet4", "testnet4", 1714777860), + ("signet", "signet", 1598918400), + ("regtest", "regtest", 1296688602), ) _ORIGIN = re.compile(r"\[(?P[0-9a-f]{8})(?P(?:/[0-9]+[h']?)*)\]") @@ -57,6 +59,18 @@ def parse_fingerprint(text: str) -> bytes: ) +def parse_creation_date(text: str) -> int: + """Turn the record's approximate creation date into a rescan start; blank searches all history.""" + try: + midnight = calendar.timegm(time.strptime(text.strip(), "%Y-%m-%d")) if text.strip() else 0 + except ValueError: + raise ValueError("Type the creation date as YYYY-MM-DD, or leave it blank.") from None + if midnight > time.time() + 14 * 3600: # Still in the future at UTC+14, the earliest time zone. + raise ValueError("That creation date is in the future.") + # A day early covers whatever time zone the record's date was written in. + return max(0, midnight - 86400) + + def identifier_note(origin: str | None) -> str: """Say what `identifier_origin` found, for an operator restoring without a record.""" if origin is None: @@ -107,7 +121,7 @@ def connect( if executable is None: raise BitcoinCoreError("Install a reviewed bitcoin-cli before creating a backup.") choices: list[BitcoinCore] = [] - for chain, _label in _CHAINS: + for chain, _label, _genesis in _CHAINS: client = cls(executable, chain, 0) try: network = client._rpc("getnetworkinfo", timeout=5) @@ -130,7 +144,7 @@ def connect( raise BitcoinCoreError("More than one local Bitcoin Core network is running.") tell("Local Bitcoin Core networks:") for number, choice in enumerate(choices, 1): - tell(f" {number}. {dict(_CHAINS)[choice.chain]}") + tell(f" {number}. {dict(c[:2] for c in _CHAINS)[choice.chain]}") while not ( (answer := ask("Choose a network number")).isdecimal() and 1 <= int(answer) <= len(choices) ): @@ -138,7 +152,7 @@ def connect( choices = [choices[int(answer) - 1]] client = choices[0] if tell is not None: - tell(f"Using Bitcoin Core on {dict(_CHAINS)[client.chain]}.") + tell(f"Using Bitcoin Core on {dict(c[:2] for c in _CHAINS)[client.chain]}.") return client def _rpc( @@ -226,6 +240,35 @@ def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None "Bitcoin Core was not changed." ) + def check_history(self, timestamp: int | Literal["now"]) -> None: + """Refuse a rescan that needs blocks Core lacks: Core would import the keys and lose the history.""" + if timestamp == "now": # A fresh seed has no history; Core itself warns while its wallets catch up. + return + genesis = next(born for chain, _label, born in _CHAINS if chain == self.chain) + info = c if isinstance(c := self._rpc("getblockchaininfo"), dict) else {} + height = info.get("pruneheight") if (p := info.get("pruned")) is True else 0 if p is False else None + # A real median time lies between the chain's genesis and Core's two-hour limit on future blocks. + tip = t if type(t := info.get("mediantime")) is int and genesis <= t < time.time() + 7200 else None + if type(height) is not int or height < 0 or type(tip) is not int: + raise BitcoinCoreError("Bitcoin Core did not say which blocks it still keeps.") + start = min(tip, timestamp) # Core scans a future timestamp from the tip, with the same window. + # Until background validation ends, an AssumeUTXO node lacks the blocks below its snapshot. + cs = g.get("chainstates") or None if isinstance(g := self._rpc("getchainstates"), dict) else None + if type(cs) is not list or not all(type(c) is dict and c.get("validated") is True for c in cs): + raise BitcoinCoreError("Bitcoin Core is still downloading older blocks. Try again later.") + if height > 0: + b = self._rpc("getblockstats", str(height), '["time"]') + # Core starts two hours early, at the first block whose highest time so far reaches the + # timestamp. Block times are not monotonic, so a whole day of margin covers an earlier block + # that ran ahead of this one; dates are only day-accurate anyway. No block predates genesis. + kept = t if isinstance(b, dict) and type(t := b.get("time")) is int and t >= genesis else 0 + if start < kept + 86400 or not kept: + since = f" from before {time.strftime('%Y-%m-%d', time.gmtime(kept))}" if kept else "" + raise BitcoinCoreError( + f"This pruned node no longer has the blocks{since} that this wallet's history may be in. " + "Enter a later creation date if your wallet record has one. Bitcoin Core was not changed." + ) + def _root_xpub(self, wallet: str) -> str: result = self._rpc("gethdkeys", wallet=wallet) if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict): @@ -415,6 +458,8 @@ def initialize( if state is None: tell("That wallet is no longer eligible. Choose again.") continue + # Checked last, after any wait above: a pruning node keeps pruning while it waits. + self.check_history(timestamp) records = core_descriptors( secret, account=account, diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 27e58a9..7b10a8b 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -15,6 +15,7 @@ FingerprintMismatch, identifier_note, identifier_origin, + parse_creation_date, parse_fingerprint, ) from codex32._cli_input import ( @@ -69,6 +70,7 @@ class _CliContext(NamedTuple): _GENERIC = _CliContext("codex32", False, None, "") _MASTER_SEED = _CliContext("ms32", True, (Profile.MS,), "MS1") +_FIRST_USED = "Date this seed was first used, as YYYY-MM-DD (Enter: all history; unused: today)" class _CommandError(Exception): @@ -357,6 +359,15 @@ def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool: return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes") +def _creation_start() -> int: + """Ask when an existing seed was first used, so a pruned node rescans only what it still has.""" + while True: + try: + return parse_creation_date(_text(_FIRST_USED, optional=True)) + except ValueError as error: + _print(str(error), err=True) + + def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed) -> bytes | None: """Take the master fingerprint from a recovery record until the library accepts it.""" prompt = "Type the master fingerprint from your wallet record (Enter if none)" @@ -486,6 +497,7 @@ def _create( raise _UsageError("Use --existing when supplying a seed or secret.") if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") + start: int | Literal["now"] = _creation_start() if existing else "now" try: if threshold == 0: if isinstance(source, MasterSeed): @@ -499,11 +511,7 @@ def _create( _emit(secret, False, fingerprint=core.fingerprint) if sys.stdin.isatty(): _confirm_card(secret) - return ( - _initialize_wallet(core, secret, timestamp=0 if existing else "now", fresh=not existing) - if core is not None - else 0 - ) + return _initialize_wallet(core, secret, timestamp=start, fresh=not existing) if isinstance(source, MasterSeed): ceremony = CreationCeremony.from_secret( source, @@ -541,10 +549,7 @@ def _create( _print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True) finished = ceremony.finish() assert isinstance(finished, MasterSeed) - if core is not None: - return _initialize_wallet(core, finished, timestamp=0 if existing else "now", fresh=not existing) - _print("\nEvery recovery card was confirmed from its re-entered text.", err=True) - return 0 + return _initialize_wallet(core, finished, timestamp=start, fresh=not existing) def _correct( diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 036514c..fdd3008 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -872,7 +872,7 @@ def make(passphrase: str) -> None: def job() -> Record: if restoring: - wallet_setup.verify(core, secret, expected) + wallet_setup.verify(core, secret, expected, timestamp) wallet_setup.create(core, chosen, passphrase) return _record(core, secret, chosen, timestamp, expected, passphrase) @@ -1065,13 +1065,16 @@ def _fingerprint_page( ) -> Adw.NavigationPage: """Take the master fingerprint from the wallet record. The library refuses a mismatch.""" entered = Adw.EntryRow(title="Master fingerprint from your wallet record") + dated = Adw.EntryRow(title="Approximate creation date from the record, as YYYY-MM-DD") group = Adw.PreferencesGroup() - group.add(entered) + for row in (entered, dated) if restoring else (entered,): + group.add(row) status = _note(problem, "error" if problem else "") def go() -> None: try: expected = wallet_setup.parse_fingerprint(entered.get_text()) + start = wallet_setup.parse_creation_date(dated.get_text()) if restoring else timestamp except ValueError as error: _say(status, str(error), "error") return @@ -1079,15 +1082,15 @@ def go() -> None: def job() -> str: try: - wallet_setup.verify(core, secret, expected) - except wallet_setup.FingerprintMismatch as error: + wallet_setup.verify(core, secret, expected, start) + except wallet_setup.BitcoinCoreError as error: return str(error) return "" def follow(mismatch: str) -> Adw.NavigationPage | None: if mismatch: return _fingerprint_page(view, core, secret, timestamp, restoring=restoring, problem=mismatch) - _wallets(view, core, secret, timestamp, expected, restoring=restoring) + _wallets(view, core, secret, start, expected, restoring=restoring) return None work.run(view, page, job, _then(view, page, follow, CARDS_SAFE)) diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index 11abc2c..58de0c1 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -32,9 +32,12 @@ FingerprintMismatch, identifier_note, identifier_origin, + parse_creation_date, parse_fingerprint, ) +Timestamp = int | Literal["now"] + __all__ = [ "NO_RECORD_WARNING", "UNLOCK_SECONDS", @@ -52,6 +55,7 @@ "identity", "initialize", "network", + "parse_creation_date", "parse_fingerprint", "relock", "require_unlocked", @@ -146,7 +150,7 @@ def connect(chain: str | None = None) -> BitcoinCore: def network(core: BitcoinCore) -> str: """Return the chain this connection selected, named the way the operator chose it.""" - return dict(_CHAINS).get(core.chain, core.chain) + return dict(c[:2] for c in _CHAINS).get(core.chain, core.chain) def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str: @@ -160,9 +164,10 @@ def identity(core: BitcoinCore, secret: MasterSeed) -> tuple[str, str]: return fingerprint.hex(), identifier_note(identifier_origin(secret, fingerprint)) -def verify(core: BitcoinCore, secret: MasterSeed, expected: bytes | None) -> None: - """Refuse a seed that is not the recorded wallet before any wallet is listed or touched.""" +def verify(core: BitcoinCore, secret: MasterSeed, expected: bytes | None, start: Timestamp) -> None: + """Refuse a seed that is not the recorded wallet, or pruned history, before any wallet is touched.""" core.verify_identity(secret, expected) + core.check_history(start) def fingerprint_provider(core: BitcoinCore) -> Callable[[bytes], bytes]: @@ -219,19 +224,23 @@ def _passphrase(passphrase: str) -> str: def create(core: BitcoinCore, name: str, passphrase: str) -> None: """Create one blank descriptor wallet with private keys enabled, and nothing else.""" + # Loaded at every start, the wallet follows the chain, so a pruned node never prunes past it. arguments = [f"wallet_name={_wallet_name(name)}", "disable_private_keys=false", "blank=true"] + arguments.append("load_on_startup=true") if passphrase: arguments.append(f"passphrase={_passphrase(passphrase)}") try: - core._rpc("-named", "createwallet", stdin="\n".join(arguments) + "\n") + created = core._rpc("-named", "createwallet", stdin="\n".join(arguments) + "\n") except UnicodeEncodeError: raise BitcoinCoreError(_UNSENDABLE) from None except BitcoinCoreError as error: raise BitcoinCoreError( "Bitcoin Core would not create a wallet with that name. A wallet of that name may exist already." ) from error - if core._target(name) is None: - raise BitcoinCoreError("Bitcoin Core did not create an empty wallet that codex32 can fill.") + # Core still creates the wallet when it cannot save load_on_startup, and only warns. + notes = got if isinstance(created, dict) and type(got := created.get("warnings", [])) is list else [None] + if core._target(name) is None or any(type(w) is not str or "could not be updated" in w for w in notes): + raise BitcoinCoreError("Bitcoin Core did not create an empty wallet that it loads at every start.") def relock(core: BitcoinCore, name: str) -> None: @@ -320,7 +329,7 @@ def fill( """ if not passphrase: return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) - verify(core, secret, expected) + verify(core, secret, expected, timestamp) unlock(core, name, passphrase) try: return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 84aad2c..a7c954d 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -16,6 +16,7 @@ FingerprintMismatch, identifier_note, identifier_origin, + parse_creation_date, parse_fingerprint, ) from codex32.bip93 import parse_codex32 @@ -336,6 +337,10 @@ def __call__( command = arguments[0] if command == "listwallets": return ["signer"] + if command == "getblockchaininfo": + return {"pruned": False, "mediantime": 1700000000} + if command == "getchainstates": + return {"chainstates": [{"validated": True}]} if command == "getwalletinfo": encryption = {"unlocked_until": 0 if self.locked else 100} if self.encrypted else {} return _empty_info(private_keys_enabled=self.private, **encryption) @@ -800,3 +805,169 @@ def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> No assert "split shares" in note assert "supplied seed bytes" in note assert "explicit identifier" in note + + +@pytest.mark.parametrize( + ("text", "expected"), + (("", 0), (" ", 0), ("2024-03-02", 1709251200), (" 2024-03-02 ", 1709251200), ("1970-01-01", 0)), +) +def test_a_creation_date_starts_the_rescan_a_day_early(text: str, expected: int) -> None: + assert parse_creation_date(text) == expected + + +@pytest.mark.parametrize("text", ("2024-13-01", "March 2024", "9999-01-01")) +def test_an_unusable_creation_date_is_refused(text: str) -> None: + with pytest.raises(ValueError): + parse_creation_date(text) + + +def test_tomorrow_is_refused_before_the_day_of_margin_could_hide_it(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "codex32._bitcoin_core.time.time", lambda: 1709251200 + 11 * 3600 + ) # 2024-03-01 11:00Z + assert parse_creation_date("2024-03-02") == 1709251200 # Already 2024-03-02 at UTC+14. + with pytest.raises(ValueError, match="future"): + parse_creation_date("2024-03-03") + + +def _pruned( + monkeypatch: pytest.MonkeyPatch, + chain: dict[str, object], + kept: int, + states: object = ({"validated": True},), +) -> list[tuple[str, ...]]: + calls: list[tuple[str, ...]] = [] + + def rpc(_client: BitcoinCore, *arguments: str, **_keywords: object) -> object: + calls.append(arguments) + if arguments == ("getblockchaininfo",): + return {"mediantime": 1750000000, **chain} + if arguments == ("getchainstates",): + return {"chainstates": list(states)} if isinstance(states, tuple) else states + assert arguments == ("getblockstats", "800000", '["time"]') + return {"time": kept} + + monkeypatch.setattr(BitcoinCore, "_rpc", rpc) + return calls + + +def test_a_rescan_into_pruned_blocks_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _pruned(monkeypatch, {"pruned": True, "pruneheight": 800000}, 1690000000) + with pytest.raises(BitcoinCoreError, match="before 2023-07-22"): + BitcoinCore("bitcoin-cli", "main", 320000).check_history(0) + assert calls == [("getblockchaininfo",), ("getchainstates",), ("getblockstats", "800000", '["time"]')] + + +def test_the_history_check_is_the_last_step_before_import(monkeypatch: pytest.MonkeyPatch) -> None: + rpc = _ImportRPC() + rpc.locked = False + order: list[str] = [] + monkeypatch.setattr( + BitcoinCore, + "_rpc", + lambda client, *args, wallet=None, stdin=None: ( + order.append(args[0]) or rpc(client, *args, wallet=wallet, stdin=stdin) + ), + ) + monkeypatch.setattr(BitcoinCore, "check_history", lambda _client, _timestamp: order.append("check")) + client = BitcoinCore("bitcoin-cli", "main", 320000) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=None, timestamp=0 + ) + assert order.index("check") == order.index("importdescriptors") - 1 + + +def test_a_block_time_before_the_selected_chains_genesis_fails_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _pruned(monkeypatch, {"pruned": True, "pruneheight": 800000}, 1231006505) + with pytest.raises(BitcoinCoreError): + BitcoinCore("bitcoin-cli", "testnet4", 320000).check_history(1749000000) + + +@pytest.mark.parametrize("kept", (True, 0, -1, 1, 1231006504, "1690000000", None)) +def test_an_unclear_block_time_fails_closed(monkeypatch: pytest.MonkeyPatch, kept: object) -> None: + _pruned(monkeypatch, {"pruned": True, "pruneheight": 800000}, kept) # type: ignore[arg-type] + with pytest.raises(BitcoinCoreError): + BitcoinCore("bitcoin-cli", "main", 320000).check_history(1790000000) + + +@pytest.mark.parametrize( + "chain", + ( + None, + [], + {}, + {"pruned": "yes"}, + {"pruned": 1}, + {"pruned": True}, + {"pruned": True, "pruneheight": "800000"}, + {"pruned": True, "pruneheight": -1}, + ), +) +def test_unclear_pruning_metadata_fails_closed(monkeypatch: pytest.MonkeyPatch, chain: object) -> None: + monkeypatch.setattr(BitcoinCore, "_rpc", lambda _client, *_arguments, **_keywords: chain) + with pytest.raises(BitcoinCoreError, match="which blocks"): + BitcoinCore("bitcoin-cli", "main", 320000).check_history(0) + + +def test_a_rescan_needs_a_day_past_the_pruned_blocks(monkeypatch: pytest.MonkeyPatch) -> None: + _pruned(monkeypatch, {"pruned": True, "pruneheight": 800000}, 1690000000) + with pytest.raises(BitcoinCoreError): + BitcoinCore("bitcoin-cli", "main", 320000).check_history(1690000000 + 86399) + + +def test_a_rescan_from_after_the_pruned_blocks_goes_ahead(monkeypatch: pytest.MonkeyPatch) -> None: + _pruned(monkeypatch, {"pruned": True, "pruneheight": 800000}, 1690000000) + BitcoinCore("bitcoin-cli", "main", 320000).check_history(1690000000 + 86400) + + +@pytest.mark.parametrize("chain", ({"pruned": False}, {"pruned": True, "pruneheight": 0})) +def test_an_unpruned_node_can_rescan_from_any_date( + monkeypatch: pytest.MonkeyPatch, chain: dict[str, object] +) -> None: + calls = _pruned(monkeypatch, chain, 0) + BitcoinCore("bitcoin-cli", "main", 320000).check_history(0) + assert calls == [("getblockchaininfo",), ("getchainstates",)] + + +def test_a_new_wallet_loads_without_asking_about_history(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _pruned(monkeypatch, {"pruned": True, "pruneheight": 800000}, 1690000000, None) + BitcoinCore("bitcoin-cli", "main", 320000).check_history("now") + assert calls == [] + + +def test_a_future_timestamp_is_checked_from_the_tip_core_scans_from(monkeypatch: pytest.MonkeyPatch) -> None: + _pruned( + monkeypatch, {"pruned": True, "pruneheight": 800000, "mediantime": 1690000000 + 86399}, 1690000000 + ) + with pytest.raises(BitcoinCoreError, match="before 2023-07-22"): + BitcoinCore("bitcoin-cli", "main", 320000).check_history(1900000000) + + +@pytest.mark.parametrize("median", (None, "1690000000", True, 1, 1231006504, 10**20)) +def test_an_impossible_tip_time_fails_closed(monkeypatch: pytest.MonkeyPatch, median: object) -> None: + _pruned(monkeypatch, {"pruned": False, "mediantime": median}, 0) + with pytest.raises(BitcoinCoreError, match="which blocks"): + BitcoinCore("bitcoin-cli", "main", 320000).check_history(1790000000) + + +@pytest.mark.parametrize( + "states", + ( + ({"validated": True}, {"validated": False, "snapshot_blockhash": "00ab"}), + ({"validated": False, "snapshot_blockhash": "00ab"},), + ({"validated": 1},), + ("validated",), + (), + None, + {"chainstates": None}, + ), +) +def test_a_restore_waits_for_assumeutxo_background_validation( + monkeypatch: pytest.MonkeyPatch, states: object +) -> None: + calls = _pruned(monkeypatch, {"pruned": False}, 0, states) + with pytest.raises(BitcoinCoreError, match="older blocks"): + BitcoinCore("bitcoin-cli", "main", 320000).check_history(1790000000) + assert calls == [("getblockchaininfo",), ("getchainstates",)] diff --git a/tests/test_cli.py b/tests/test_cli.py index f3680c8..a56476e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -172,7 +172,10 @@ def _invoke_confirmed_create( *lines: str, terminal_output: bool = False, core: _FakeBitcoinCore | None = None, + dated: str = "", ) -> _Result: + if "--existing" in args: # A blank creation date searches all history. + lines = (*lines[:1], dated, *lines[1:]) stdin = _TTYInput("\n".join(lines) + "\n") stdout = _CreationOutput(pretty=terminal_output) stderr = io.StringIO() @@ -1531,6 +1534,8 @@ def answer(prompt: str) -> str: prompts.append(prompt) if len(prompts) == 1: return VECTOR_4["secret_s"] + if prompt.startswith("Date this seed"): + return "" if prompt.startswith("Write this share"): emitted.append(_card_text(capsys.readouterr().out)) return "" @@ -1546,6 +1551,7 @@ def answer(prompt: str) -> str: assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 0 assert prompts == [ "Enter an existing Bitcoin codex32 secret or hexadecimal seed:\n> ", + "Date this seed was first used, as YYYY-MM-DD (Enter: all history; unused: today): ", "Write this share on a new recovery card, then press Enter. ", "Re-enter the share from the recovery card:\n> ", "Write this share on a new recovery card, then press Enter. ", @@ -2036,7 +2042,7 @@ def test_production_size_budgets_are_enforced() -> None: for path in package.rglob("*.py") } - assert sum(counts.values()) < 5000, counts + assert sum(counts.values()) < 5025, counts @pytest.mark.parametrize( @@ -2312,7 +2318,7 @@ def test_create_existing_secret_confirms_original_before_initializing( output = _TTYOutput() damaged = secret.text[:-1] + ("q" if secret.text[-1].lower() != "q" else "p") width = len(secret.text) % 4 or 4 - answers = iter(("", damaged, secret.text[-width:].upper())) + answers = iter(("", "", damaged, secret.text[-width:].upper())) prefills: list[str] = [] def answer(prompt: str, **options: object) -> str: @@ -2340,6 +2346,25 @@ def answer(prompt: str, **options: object) -> str: split.assert_not_called() +def test_create_existing_rescans_from_the_date_the_seed_was_first_used() -> None: + core = _FakeBitcoinCore() + result = _invoke_confirmed_create( + ["create", "--existing"], VECTOR_1["secret_s"], core=core, dated="2024-03-02" + ) + assert result.exit_code == 0 + assert core.timestamp == 1709251200 # A day early, for the record's time zone. + + +def test_an_unusable_first_use_date_is_asked_again(monkeypatch: pytest.MonkeyPatch) -> None: + cli_module = importlib.import_module("codex32.cli") + answers = iter(("March 2024", "2024-03-02")) + monkeypatch.setattr(cli_module, "_text", lambda _prompt, **_options: next(answers)) + errors = io.StringIO() + with contextlib.redirect_stderr(errors): + assert cli_module._creation_start() == 1709251200 + assert "YYYY-MM-DD" in errors.getvalue() + + def test_create_existing_secret_does_not_silently_change_identifier() -> None: result = _invoke_confirmed_create(["create", "0test", "--existing"], VECTOR_4["secret_s"]) assert result.exit_code == 2 and result.stdout == "" @@ -2360,6 +2385,7 @@ def test_create_existing_interruption_cannot_initialize_a_wallet() -> None: with ( patch.object(sys, "stdin", _TTYInput()), patch("codex32.cli._creation_source", return_value=secret), + patch("codex32.cli._creation_start", return_value=0), patch("codex32.cli._confirm_card", side_effect=KeyboardInterrupt), patch("codex32.cli.BitcoinCore.connect", return_value=core), contextlib.redirect_stdout(_TTYOutput()), @@ -2735,7 +2761,7 @@ def test_corrected_creation_source_identity_and_acceptance_boundary(monkeypatch) SimpleNamespace(artifact=secret, search_complete=True, low_checksum_discrimination=False), ), ) - answers = iter(("ms1invalid", "n", "ms1invalid", "yes", "", secret.text)) + answers = iter(("ms1invalid", "n", "ms1invalid", "yes", "", "", secret.text)) confirmations = [] def answer(prompt, prefill=""): diff --git a/tests/test_gui_restore_date.py b/tests/test_gui_restore_date.py new file mode 100644 index 0000000..afd85c1 --- /dev/null +++ b/tests/test_gui_restore_date.py @@ -0,0 +1,85 @@ +"""Restore takes the wallet's creation date from the record, so a pruned node can rescan it.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +gi = pytest.importorskip("gi") +try: + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + gi.require_version("Gdk", "4.0") + from gi.repository import Adw, Gdk, Gtk +except (ImportError, ValueError): + pytest.skip("GTK 4 and libadwaita are unavailable", allow_module_level=True) +if not Gtk.init_check() or Gdk.Display.get_default() is None: + pytest.skip("no display for GTK", allow_module_level=True) +Adw.init() + +from codex32._bitcoin_core import BitcoinCoreError +from codex32_gui import pages + + +def _widgets(root: Gtk.Widget, kind: type) -> Iterator[Gtk.Widget]: + child = root.get_first_child() + while child is not None: + if isinstance(child, kind): + yield child + yield from _widgets(child, kind) + child = child.get_next_sibling() + + +def _fill(view: Adw.NavigationView, *texts: str) -> None: + page = view.get_visible_page() + for row, text in zip(_widgets(page, Adw.EntryRow), texts, strict=True): + row.set_text(text) + next(b for b in _widgets(page, Gtk.Button) if b.get_label() == "Check and continue").emit("clicked") + + +@pytest.fixture +def restore(monkeypatch: pytest.MonkeyPatch) -> tuple[Adw.NavigationView, list[Any], list[Any]]: + checked: list[Any] = [] + reached: list[Any] = [] + + def verify(_core: Any, _secret: Any, expected: bytes | None, start: Any = "now") -> None: + checked.append((expected, start)) + if start == 0: + raise BitcoinCoreError("This pruned node no longer has the blocks") + + monkeypatch.setattr(pages.work, "run", lambda _view, _page, job, done: done(job())) + monkeypatch.setattr(pages.wallet_setup, "verify", verify) + monkeypatch.setattr(pages, "_wallets", lambda *arguments, **keywords: reached.append(arguments[3])) + view = Adw.NavigationView() + view.push(pages._fingerprint_page(view, object(), object(), 0, restoring=True)) # type: ignore[arg-type] + return view, checked, reached + + +def test_restore_rescans_from_the_recorded_creation_date(restore: Any) -> None: + view, checked, reached = restore + _fill(view, "3f3521a6", "2024-03-02") + assert checked == [(bytes.fromhex("3f3521a6"), 1709251200)] + assert reached == [1709251200] + + +def test_a_rescan_the_node_cannot_do_stays_on_the_page(restore: Any) -> None: + view, checked, reached = restore + _fill(view, "3f3521a6", "") + assert checked == [(bytes.fromhex("3f3521a6"), 0)] + assert reached == [] + shown = " ".join(label.get_label() for label in _widgets(view.get_visible_page(), Gtk.Label)) + assert "pruned node" in shown + + +def test_a_malformed_date_is_refused_before_bitcoin_core_is_asked(restore: Any) -> None: + view, checked, reached = restore + _fill(view, "3f3521a6", "March 2024") + assert checked == [] and reached == [] + + +def test_a_new_wallet_record_check_asks_for_no_date() -> None: + view = Adw.NavigationView() + page = pages._fingerprint_page(view, object(), object(), "now") # type: ignore[arg-type] + assert len(list(_widgets(page, Adw.EntryRow))) == 1 diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index 4b48081..8cfeebc 100644 --- a/tests/test_gui_wallet_setup.py +++ b/tests/test_gui_wallet_setup.py @@ -37,6 +37,7 @@ class _Core: wallets: dict[str, _Wallet] runs: list[tuple[tuple[str, ...], str | None]] = field(default_factory=list) + created_warnings: list[str] = field(default_factory=list) def run(self, command: list[str], **keywords: Any) -> subprocess.CompletedProcess[str]: supplied = keywords.get("input") @@ -57,6 +58,10 @@ def _reply(self, method: str, arguments: list[str], name: str | None) -> str: return json.dumps(sorted(self.wallets)) if method == "listdescriptors": return json.dumps({"descriptors": []}) + if method == "getblockchaininfo": + return json.dumps({"pruned": False, "mediantime": 1700000000}) + if method == "getchainstates": + return json.dumps({"chainstates": [{"validated": True}]}) if method == "getwalletinfo": assert name is not None wallet = self.wallets[name] @@ -75,7 +80,7 @@ def _reply(self, method: str, arguments: list[str], name: str | None) -> str: if method == "createwallet": fields = dict(item.split("=", 1) for item in arguments) self.wallets[fields["wallet_name"]] = _Wallet("passphrase" in fields, "passphrase" in fields) - return json.dumps({"name": fields["wallet_name"]}) + return json.dumps({"name": fields["wallet_name"], "warnings": self.created_warnings}) if method == "walletpassphrase": assert name is not None self.wallets[name].locked = False @@ -170,9 +175,39 @@ def test_wallet_creation_uses_one_fixed_set_of_flags(monkeypatch: pytest.MonkeyP command, supplied = fake.runs[0] assert "-named" in command and "createwallet" in command assert supplied is not None - assert _lines(supplied)[:3] == ["wallet_name=fresh", "disable_private_keys=false", "blank=true"] - assert _lines(supplied)[3] == f"passphrase={PASSPHRASE}" - assert len(_lines(supplied)) == 4 + assert _lines(supplied)[:4] == [ + "wallet_name=fresh", + "disable_private_keys=false", + "blank=true", + "load_on_startup=true", + ] + assert _lines(supplied)[4] == f"passphrase={PASSPHRASE}" + assert len(_lines(supplied)) == 5 + + +def test_a_wallet_core_will_not_load_at_its_next_start_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {}) + fake.created_warnings.append( + "Wallet load on startup setting could not be updated, so wallet may not be loaded next node startup." + ) + with pytest.raises(wallet_setup.BitcoinCoreError, match="loads at every start"): + wallet_setup.create(core, "fresh", "") + + +@pytest.mark.parametrize("warnings", ("could not be saved", None, [None], [1], {"a": 1})) +def test_an_unexpected_createwallet_result_is_refused( + monkeypatch: pytest.MonkeyPatch, warnings: object +) -> None: + core, fake = _client(monkeypatch, {}) + fake.created_warnings = warnings # type: ignore[assignment] + with pytest.raises(wallet_setup.BitcoinCoreError, match="loads at every start"): + wallet_setup.create(core, "fresh", "") + + +def test_only_the_warnings_are_searched_for_the_failed_setting(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {}) + wallet_setup.create(core, "could not be updated", "") + assert "could not be updated" in fake.wallets def test_a_wallet_created_without_a_passphrase_carries_no_passphrase_line( @@ -181,7 +216,7 @@ def test_a_wallet_created_without_a_passphrase_carries_no_passphrase_line( core, fake = _client(monkeypatch, {}) wallet_setup.create(core, "fresh", "") assert fake.runs[0][1] is not None - assert len(_lines(fake.runs[0][1])) == 3 + assert len(_lines(fake.runs[0][1])) == 4 @pytest.mark.parametrize("name", ["", " leading", "trailing ", "two\nlines", "bell\x07"]) @@ -261,7 +296,7 @@ def refuse(*_arguments: object, **_keywords: object) -> str: def test_identity_mismatch_is_refused_before_unlock(monkeypatch: pytest.MonkeyPatch) -> None: core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) - def mismatch(_core: BitcoinCore, _secret: object, _expected: bytes | None) -> None: + def mismatch(_core: BitcoinCore, _secret: object, _expected: bytes | None, _start: object) -> None: raise FingerprintMismatch("wrong wallet") monkeypatch.setattr(wallet_setup, "verify", mismatch)