diff --git a/AGENTS.md b/AGENTS.md index cc230287..7fb0f330 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 1b829ef7..0f2ef019 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 b3f7ce8b..2fbcbf2e 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,13 @@ 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 the wallet keeps +processing new blocks. `create` returns Core's warnings, including a failed +startup-setting save, for display on the finished page. Core owns this policy; +the GUI does not maintain a second wallet-loading mechanism. 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 ffd83ee0..e8b59a56 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 4b11d95d..6b005829 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, persist startup loading in its own settings so the wallet can follow new blocks after restart. Core creation warnings, including a failed startup-setting save, are displayed to the operator on the finished page; codex32 adds no second startup-loading or quarantine policy. 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 c813f7ae..6955e97f 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -168,6 +168,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 fee2ba0d..a9da4992 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 f7cf279b..26380b1f 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( @@ -214,10 +228,7 @@ def fingerprint(self, secret: MasterSeed) -> bytes: return self.fingerprint_seed(secret.seed_bytes) def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None: - """Refuse a recovered seed that is not the recorded wallet, before any wallet is touched. - - `None` is the operator's explicit choice to restore without a record; nothing is checked. - """ + """Check the recorded fingerprint before touching a wallet; None explicitly opts out.""" if expected_fingerprint is None: return if self.fingerprint(secret) != expected_fingerprint: @@ -226,6 +237,37 @@ 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 = b.get("time") if isinstance(b, dict) else None + if type(kept) is not int or not genesis <= kept < time.time() + 7200: + raise BitcoinCoreError("Bitcoin Core did not say which blocks it still keeps.") + if start < kept + 86400: + since = time.strftime("%Y-%m-%d", time.gmtime(kept)) + raise BitcoinCoreError( + f"This pruned node no longer has the blocks from before {since} that this wallet may need. " + "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 +457,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 27e58a9e..7b10a8b0 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 c367d8ed..6ed80813 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -76,8 +76,9 @@ "Check each of these against your wallet record. They should all match.", ( "If the master fingerprint is not the one on your record, these cards do not belong to that " - "wallet: stop, and do not send anything to it. Bitcoin Core is now scanning the chain from " - "the beginning, so your balance and history are not complete until it has finished." + "wallet: stop, and do not send anything to it. Bitcoin Core scans from the creation date you " + "supplied, or from the beginning if you left it blank. Earlier payments can be missed if the " + "date was too late. Let Core finish syncing, then check your balance and past payments." ), ) CARDS_SAFE = ( @@ -870,17 +871,19 @@ def make(passphrase: str) -> None: chosen = name.get_text().strip() page = _working(view, "Bitcoin Core", "Creating the wallet and writing your keys into it…") - def job() -> Record: + def job() -> tuple[Record, tuple[str, ...]]: if restoring: - wallet_setup.verify(core, secret, expected) - wallet_setup.create(core, chosen, passphrase) - return _record(core, secret, chosen, timestamp, expected, passphrase) + wallet_setup.verify(core, secret, expected, timestamp) + warnings = wallet_setup.create(core, chosen, passphrase) + return _record(core, secret, chosen, timestamp, expected, passphrase), warnings work.run( view, page, job, - _then(view, page, lambda record: _finished_page(view, record, restoring), CARDS_SAFE), + _then( + view, page, lambda result: _finished_page(view, result[0], restoring, result[1]), CARDS_SAFE + ), ) def go() -> None: @@ -1065,13 +1068,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 +1085,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)) @@ -1135,14 +1141,10 @@ def _import( ) -def _finished_page(view: Adw.NavigationView, record: Record, restoring: bool = False) -> Adw.NavigationPage: - """Show the wallet-identity fields. - - A new wallet's are copied onto the wallet record. A restored wallet's are the - final accident-safety checks that the cards just entered match the recorded - wallet, so they are checked against the record instead, and no creation date is offered: the one this - wallet was born with is on the record already, and today's would replace it. - """ +def _finished_page( + view: Adw.NavigationView, record: Record, restoring: bool = False, warnings: tuple[str, ...] = () +) -> Adw.NavigationPage: + """Show identity and Core warnings; only new wallets get a date to record.""" heading, asked, closing = _RESTORED if restoring else _CREATED dated = () if restoring else (("Approximate creation date", time.strftime("%Y-%m-%d")),) content = _column( @@ -1160,6 +1162,7 @@ def _finished_page(view: Adw.NavigationView, record: Record, restoring: bool = F ), ), _note(closing, "warning" if restoring else ""), + *(_note(warning, "warning") for warning in warnings), ) return _page( "Finished", diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index 11abc2c5..a9cf6d32 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -1,16 +1,7 @@ -"""Every Bitcoin Core interaction this program performs, including the passphrase. - -`docs/security/invariants.md` invariant 9 states that codex32 has no passphrase -channel, and the library still has none: it tells the operator to unlock the -wallet in Bitcoin-Qt. The graphical program declares one deliberate exception, -confined to this file, because a person who has just written three cards by hand -should not have to open a second application and type a console command. - -The passphrase reaches `bitcoin-cli` through `-stdinwalletpassphrase` and -`-stdin`, never through a command argument, so it is absent from `/proc` and -`ps`. It is never stored, never logged, and never written to disk. Import and -verification remain the library's `BitcoinCore.initialize`, and `fill` holds a -`finally`-protected obligation to lock again any wallet this file unlocked. +"""The GUI's declared Core passphrase boundary (security invariant 9). + +Passphrases use bitcoin-cli stdin, never arguments or disk; fill relocks on exit. +Core owns startup loading. Return its creation warnings for display. """ from __future__ import annotations @@ -32,9 +23,12 @@ FingerprintMismatch, identifier_note, identifier_origin, + parse_creation_date, parse_fingerprint, ) +Timestamp = int | Literal["now"] + __all__ = [ "NO_RECORD_WARNING", "UNLOCK_SECONDS", @@ -52,6 +46,7 @@ "identity", "initialize", "network", + "parse_creation_date", "parse_fingerprint", "relock", "require_unlocked", @@ -146,7 +141,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 +155,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]: @@ -217,21 +213,31 @@ def _passphrase(passphrase: str) -> str: return passphrase -def create(core: BitcoinCore, name: str, passphrase: str) -> None: - """Create one blank descriptor wallet with private keys enabled, and nothing else.""" +def create(core: BitcoinCore, name: str, passphrase: str) -> tuple[str, ...]: + """Create one blank descriptor wallet and return Core's warnings for the operator.""" + # Let Core persist startup loading so the wallet follows new blocks. 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.") + notes = created.get("warnings", []) if isinstance(created, dict) else None + if ( + not isinstance(created, dict) + or created.get("name") != name + or core._target(name) is None + or type(notes) is not list + or any(type(w) is not str for w in notes) + ): + raise BitcoinCoreError("Bitcoin Core did not return an empty wallet and its warnings.") + return tuple(notes) def relock(core: BitcoinCore, name: str) -> None: @@ -320,7 +326,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 84aad2c5..39bc291b 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, 10**20)) +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 f3680c8d..a56476e6 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_boundaries.py b/tests/test_gui_boundaries.py index b71fa67b..1d73c9c7 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -151,7 +151,7 @@ def test_restore_verifies_identity_before_creating_a_destination_wallet() -> Non assert isinstance(verify, ast.Expr) and isinstance(verify.value, ast.Call) assert isinstance(verify.value.func, ast.Attribute) and verify.value.func.attr == "verify" create = job.body[1] - assert isinstance(create, ast.Expr) and isinstance(create.value, ast.Call) + assert isinstance(create, ast.Assign) and isinstance(create.value, ast.Call) assert isinstance(create.value.func, ast.Attribute) and create.value.func.attr == "create" diff --git a/tests/test_gui_restore_date.py b/tests/test_gui_restore_date.py new file mode 100644 index 00000000..19c95a77 --- /dev/null +++ b/tests/test_gui_restore_date.py @@ -0,0 +1,94 @@ +"""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 + + +def test_core_warnings_are_visible_on_the_finished_page() -> None: + view = Adw.NavigationView() + record = pages.Record("name", "wallet", "32.0.0", "3f3521a6", 0) + warning = "Wallet load on startup setting could not be updated." + page = pages._finished_page(view, record, True, (warning,)) + shown = " ".join(label.get_label() for label in _widgets(page, Gtk.Label)) + assert warning in shown diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index 4b48081c..97379a2b 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,40 @@ 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_core_startup_persistence_warnings_are_returned_to_the_operator( + 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." + ) + assert wallet_setup.create(core, "fresh", "") == tuple(fake.created_warnings) + + +@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="empty wallet and its warnings"): + 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 +217,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 +297,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)