From 51014ad37ebdce11dbafba5491b9a5ed0e93440e Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 10 Oct 2026 05:16:55 -0500 Subject: [PATCH 1/2] gui: Prepare blank cards and wallet record Introduce a pre-creation checklist, packaged printable forms and fallback paths for older GTK and Tails. Include the final handwriting conventions for easily confused codex32 symbols and update GUI tests, security documentation, and the approved reviewability budget. Refs BenWestgate/Bails#314 --- README.md | 4 +- docs/developer/api.md | 2 +- docs/developer/gui.md | 10 +- docs/security/model.md | 7 +- docs/user/gui.md | 25 +++- docs/user/guide.md | 7 +- pyproject.toml | 2 +- src/codex32_gui/__init__.py | 1 + .../codex32_gui/forms}/recovery-card.html | 4 +- .../forms}/wallet-verification-record.html | 0 src/codex32_gui/pages.py | 71 +++++++++-- tests/test_gui_before_you_start.py | 117 ++++++++++++++++++ tests/test_gui_boundaries.py | 16 ++- 13 files changed, 240 insertions(+), 26 deletions(-) rename {docs/user => src/codex32_gui/forms}/recovery-card.html (94%) rename {docs/user => src/codex32_gui/forms}/wallet-verification-record.html (100%) create mode 100644 tests/test_gui_before_you_start.py diff --git a/README.md b/README.md index ad5d83b..e015a65 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ codex32 strings. Printable forms: -- [codex32 recovery card](docs/user/recovery-card.html) -- [wallet-verification record](docs/user/wallet-verification-record.html) +- [codex32 recovery card](src/codex32_gui/forms/recovery-card.html) +- [wallet-verification record](src/codex32_gui/forms/wallet-verification-record.html) ## For developers and reviewers diff --git a/docs/developer/api.md b/docs/developer/api.md index 6c7c801..1b829ef 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -113,7 +113,7 @@ documentation and enforcement update. 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 1,800 logical review lines, separate +dependency. It carries its own budget of 2,050 logical review lines, separate from the 5,000 above. Its own boundaries are documented in [`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`. diff --git a/docs/developer/gui.md b/docs/developer/gui.md index c922ce7..b3f7ce8 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -28,12 +28,13 @@ without a display and run in ordinary CI. `tools/gui_walkthrough.py` drives the 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,000 logical lines, separate from the +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 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 -`pages.py`, which is the first priority this program was built for. +`pages.py`, which is the first priority this program was built for. It was +2,000 until the **Before you start** page and handwriting key of 2026-10-03. ## Claims, and how to check each one @@ -43,7 +44,10 @@ about 250 lines, and the rest of the difference is user-facing wording in `hashlib`, or `hmac`. Entropy belongs to `CreationCeremony`. 2. **No network.** Nothing imports `socket`, `ssl`, `urllib`, or `http`, and no module imports `subprocess`. The only child process is the `bitcoin-cli` the - library already starts. + library already starts. Separately, `_ready_page` may ask the desktop to open + a bundled blank form with `Gtk.FileLauncher`; it is the only caller. It + passes a path under `codex32_gui/forms/`, or under `CODEX32_FORMS_DIR` when a + launcher has copied the forms where a confined browser can read them. 3. **Nothing reaches disk.** Nothing imports `os`, `pathlib`, `io`, `tempfile`, `shutil`, `pickle`, `sqlite3`, or `logging`, and nothing calls `open`. There is no settings file, no recent list, no log, and no clipboard write. diff --git a/docs/security/model.md b/docs/security/model.md index c14f9fd..4b11d95 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -278,7 +278,12 @@ fingerprint, the identifier result, and the warning before the operator chooses. The program draws no entropy, opens no socket, starts no process of its own, and writes no file: no settings, no recent list, no log, and no clipboard write of -recovery text. Entered recovery text is cleared when its screen is left, subject +recovery text. One button pair is the exception to "no process": **Before you +start** can ask the desktop, through GTK's `FileLauncher`, to open one of the two +blank printable forms shipped in `codex32_gui/forms/`. The desktop chooses and +starts the viewer. Only those forms are passed, never recovery text, and this +happens before any seed is drawn. A launcher may set `CODEX32_FORMS_DIR` to a +copy of the forms that a confined browser can read; Bails does this on Tails. Entered recovery text is cleared when its screen is left, subject to the zeroization limitation above. Two disclosure channels belong to the toolkit rather than to this program, and diff --git a/docs/user/gui.md b/docs/user/gui.md index 019ebb4..c813f7a 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -62,15 +62,23 @@ Choose how many cards you want. Three cards where any two recover the wallet is the recommended shape: one card can be lost, burned or stolen and your bitcoin is still safe, and one card on its own tells a finder nothing. +Next, **Before you start** asks you to have one blank recovery card per card, a +pen, and one wallet record ready. Its buttons open the printable +[recovery card](../../src/codex32_gui/forms/recovery-card.html) and +[wallet record](../../src/codex32_gui/forms/wallet-verification-record.html) +forms in your browser. Print them blank and fill them in by hand, ideally in +archival ink. Never print a filled-in card: printers and print queues can keep +a copy of what they printed. Press **I have them ready** to see the first card. + Each card is shown once. Copy it onto paper with a pen, then type it back from the paper with the original off the screen. That catches a slip of the pen now rather than years from now. If a group does not match, the window says which one; correct that group and try again, as many times as you like. When every card is confirmed, the window shows the master fingerprint. Write it -on your [wallet record](wallet-verification-record.html), then press **I wrote it -down**. This is a new wallet ceremony, so there is no pre-existing fingerprint -or descriptor to authenticate against. +on your wallet record, then press **I wrote it down**. This is a new wallet +ceremony, so there is no pre-existing fingerprint or descriptor to authenticate +against. Next, choose the Bitcoin Core wallet that will hold the keys. Only empty wallets are offered, so no wallet you already use can be @@ -80,15 +88,20 @@ give it a name and a passphrase, and codex32 fills it in and locks it again. Forgetting that passphrase does not lose your bitcoin. Your cards still recover the seed. It protects the wallet on this computer. -Finally, copy the wallet details onto your -[wallet record](wallet-verification-record.html) and keep it apart from every -card. The window shows exactly the fields that record asks for. +Finally, copy the wallet details onto your wallet record and keep it apart from +every card. The window shows exactly the fields that record asks for. A card never contains **B**, **I**, **O** or **1**: those four are left out of the alphabet precisely because handwriting confuses them with 8, J, L and 0. If you type one, the window says so and names what the card probably says, rather than quietly swallowing it. +Some characters that are left in still look alike in handwriting: 5 and S, 6 +and G, 2 and Z. While you write, the window asks you to mark them: slash every +0, cross 7 and Z, draw S with a line through it like $, close the loops of 6 +and 9, and give G an open, obvious bar. The recovery card form repeats this +key. + ## A card that is damaged Type what you can still read, and `?` for each character you cannot make out. diff --git a/docs/user/guide.md b/docs/user/guide.md index 0ccdc21..fee2ba0 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -63,8 +63,11 @@ You will need: - Bitcoin Core 32 or newer, with its local RPC server enabled and `bitcoin-cli` available on `PATH`; - codex32 installed using the [README instructions](../../README.md#install); -- one blank [codex32 recovery card](recovery-card.html) per secret or share; and -- a separately stored [wallet-verification record](wallet-verification-record.html). +- one blank + [codex32 recovery card](../../src/codex32_gui/forms/recovery-card.html) + per secret or share; and +- a separately stored + [wallet-verification record](../../src/codex32_gui/forms/wallet-verification-record.html). Before running `create` for a real wallet, have your blank cards, a pen, and wallet record ready, and choose separate trusted places for shared cards. diff --git a/pyproject.toml b/pyproject.toml index 7aa1426..6ccc768 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dev = [ where = ["src"] [tool.setuptools.package-data] -codex32_gui = ["artwork/*.png", "artwork/LICENSE"] +codex32_gui = ["artwork/*.png", "artwork/LICENSE", "forms/*.html"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/codex32_gui/__init__.py b/src/codex32_gui/__init__.py index 676e5e3..9e14f11 100644 --- a/src/codex32_gui/__init__.py +++ b/src/codex32_gui/__init__.py @@ -20,6 +20,7 @@ __version__ = "1.0.0rc1" ARTWORK = files("codex32_gui").joinpath("artwork") +FORMS = files("codex32_gui").joinpath("forms") if find_spec("gi") is not None: import gi diff --git a/docs/user/recovery-card.html b/src/codex32_gui/forms/recovery-card.html similarity index 94% rename from docs/user/recovery-card.html rename to src/codex32_gui/forms/recovery-card.html index 361e153..8a5309e 100644 --- a/docs/user/recovery-card.html +++ b/src/codex32_gui/forms/recovery-card.html @@ -47,7 +47,9 @@

Protected codex32 text — copy exactly in four-character groups

-

Leave unused boxes blank.

+

Leave unused boxes blank. Mark the look-alikes: slash every 0, cross 7 and Z, + draw S with a line through it like $, close the loops of 6 and 9, and give G an + open, obvious bar.

Offline recovery

    diff --git a/docs/user/wallet-verification-record.html b/src/codex32_gui/forms/wallet-verification-record.html similarity index 100% rename from docs/user/wallet-verification-record.html rename to src/codex32_gui/forms/wallet-verification-record.html diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index a73de64..c367d8e 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import Literal -from gi.repository import Adw, Gtk +from gi.repository import Adw, Gio, GLib, Gtk from codex32 import ( ConfirmationResult, @@ -28,7 +28,7 @@ ) from codex32.errors import CodexError from codex32.generation import ORDINARY_INDICES -from codex32_gui import ARTWORK, reading, wallet_setup, work +from codex32_gui import ARTWORK, FORMS, reading, wallet_setup, work from codex32_gui.entry import Codex32Entry from codex32_gui.wallet_setup import BitcoinCore @@ -46,6 +46,10 @@ (0, 1, "One card"), ) CREATE_WALLET = "Create a new wallet" +HANDWRITING = ( + "Mark the look-alikes as you write: slash every 0, cross 7 and Z, write S like $, close the loops of 6 " + "and 9, and give G an open, obvious bar. Then 5 and S, 6 and G, and 2 and Z stay apart." +) NO_CAMERA = ( "Do not photograph this and do not type it into any website, chat or password manager. " "Paper and pen only." @@ -56,8 +60,8 @@ ) GUESSWORK = ( "This was worked out from what you could still read. It was not read off the card, and codex32 " - "cannot tell you it is right. Copy it onto a fresh card, then prove it by restoring your wallet " - "and checking the master fingerprint against your wallet record." + "cannot tell you it is right. Copy it onto a fresh card, then check it by restoring your wallet " + "and comparing the master fingerprint with your wallet record." ) _CREATED = ( "Your wallet is ready", @@ -456,6 +460,7 @@ def _write_page( content = _column( _title("Write it down", where), _note("Use pen on a card you can keep dry. Copy each shaded group exactly, left to right."), + _note(HANDWRITING), shown, _note(f"Label this card {letter}. The letter after {name} is the card's name."), _note(NO_CAMERA, "warning"), @@ -603,13 +608,13 @@ def begin() -> None: chosen = list(details)[_selected(buttons)] if chosen != "Something else": threshold, count = next((t, c) for t, c, label in PRESETS if label == chosen) - _begin_cards(view, core, threshold, count, SEED_SIZES[0][0]) + view.push(_ready_page(view, core, threshold, count, SEED_SIZES[0][0])) return threshold, count = int(needed.get_value()), int(total.get_value()) if count < threshold: _failure(view, "A backup cannot need more cards than it has.") return - _begin_cards(view, core, threshold, count, SEED_SIZES[size.get_selected()][0]) + view.push(_ready_page(view, core, threshold, count, SEED_SIZES[size.get_selected()][0])) content = _column( _title( @@ -625,6 +630,56 @@ def begin() -> None: ) +def _ready_page( + view: Adw.NavigationView, core: BitcoinCore, threshold: int, count: int, byte_length: int +) -> Adw.NavigationPage: + """Ask for the cards and the wallet record before any card is shown. + + The last page asks for the wallet record, so it is asked for here, while + there is still time to fetch or print one. + """ + cards = "one blank recovery card" if count == 1 else f"{count} blank recovery cards" + status = _note( + "Each form opens in your browser. Print it blank, then fill it in by hand in archival ink. " + "Never print a filled-in card: a printer can keep a copy." + ) + + def show(name: str) -> None: + # A confined browser (Tor Browser on Tails) may not read the package, so a + # launcher can copy the forms somewhere it can and name that folder here. + folder = GLib.getenv("CODEX32_FORMS_DIR") + path = f"{folder}/{name}" if folder else str(FORMS.joinpath(name)) + + def opened(launcher: Gtk.FileLauncher, result: Gio.AsyncResult) -> None: + try: + launcher.launch_finish(result) + except GLib.Error: + _say(status, f"That form did not open. It is at {path}", "warning") + + if Gtk.check_version(4, 10, 0) is not None: # FileLauncher arrived in GTK 4.10. + _say(status, f"Open this form in a browser to print it: {path}", "warning") + return + Gtk.FileLauncher(file=Gio.File.new_for_path(path)).launch(view.get_root(), None, opened) + + content = _column( + _title("Before you start", f"Have {cards}, a pen, and one wallet record ready."), + _note( + "The wallet record is a separate sheet for the master fingerprint and the other wallet " + "details shown at the end. It cannot spend your bitcoin, but it helps confirm later that " + "restored cards match your recorded wallet before import. Keep it apart from every card." + ), + _button("Open the recovery card form", lambda: show("recovery-card.html")), + _button("Open the wallet record form", lambda: show("wallet-verification-record.html")), + status, + ) + begin = _button( + "I have them ready", + lambda: _begin_cards(view, core, threshold, count, byte_length), + style="suggested-action", + ) + return _page("New wallet", content, actions=_actions(begin)) + + def _begin_cards( view: Adw.NavigationView, core: BitcoinCore, threshold: int, count: int, byte_length: int ) -> None: @@ -1084,8 +1139,8 @@ def _finished_page(view: Adw.NavigationView, record: Record, restoring: bool = F """Show the wallet-identity fields. A new wallet's are copied onto the wallet record. A restored wallet's are the - only proof the cards just entered belong to that wallet, so they are checked - against the record instead, and no creation date is offered: the one this + 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. """ heading, asked, closing = _RESTORED if restoring else _CREATED diff --git a/tests/test_gui_before_you_start.py b/tests/test_gui_before_you_start.py new file mode 100644 index 0000000..36ce9c5 --- /dev/null +++ b/tests/test_gui_before_you_start.py @@ -0,0 +1,117 @@ +"""The create flow asks for blank cards and a wallet record before the first card.""" + +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_gui import FORMS, 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 _button(page: Gtk.Widget, label: str) -> Gtk.Button: + return next(button for button in _widgets(page, Gtk.Button) if button.get_label() == label) + + +def _texts(page: Gtk.Widget) -> str: + return " ".join(label.get_label() for label in _widgets(page, Gtk.Label)) + + +def test_a_layout_choice_leads_to_the_checklist_not_to_a_card(monkeypatch: pytest.MonkeyPatch) -> None: + began: list[tuple[Any, ...]] = [] + monkeypatch.setattr(pages, "_begin_cards", lambda *arguments: began.append(arguments)) + view = Adw.NavigationView() + core: Any = object() + view.push(pages._layout_page(view, core)) + + _button(view.get_visible_page(), "Continue").emit("clicked") + ready = view.get_visible_page() + assert began == [] + assert "Have 3 blank recovery cards, a pen, and one wallet record ready." in _texts(ready) + + _button(ready, "I have them ready").emit("clicked") + assert began == [(view, core, 2, 3, 16)] + + +def test_a_single_card_backup_asks_for_one_card() -> None: + view = Adw.NavigationView() + page = pages._ready_page(view, object(), 0, 1, 16) # type: ignore[arg-type] + assert "Have one blank recovery card, a pen, and one wallet record ready." in _texts(page) + + +@pytest.mark.parametrize( + ("label", "name"), + [ + ("Open the recovery card form", "recovery-card.html"), + ("Open the wallet record form", "wallet-verification-record.html"), + ], +) +def test_each_button_opens_its_shipped_form(monkeypatch: pytest.MonkeyPatch, label: str, name: str) -> None: + opened: list[str] = [] + + class Launcher: + def __init__(self, file: Any) -> None: + self.path = file.get_path() + + def launch(self, _parent: object, _cancellable: object, _callback: object) -> None: + opened.append(self.path) + + monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher, raising=False) + monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: None) + view = Adw.NavigationView() + page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type] + + _button(page, label).emit("clicked") + assert opened == [str(FORMS.joinpath(name))] + assert FORMS.joinpath(name).is_file() + + +def test_an_old_gtk_shows_where_the_form_is(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: "GTK is older than 4.10") + view = Adw.NavigationView() + page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type] + + _button(page, "Open the wallet record form").emit("clicked") + assert str(FORMS.joinpath("wallet-verification-record.html")) in _texts(page) + + +def test_a_launcher_can_point_the_buttons_at_a_readable_copy(monkeypatch: pytest.MonkeyPatch) -> None: + opened: list[str] = [] + + class Launcher: + def __init__(self, file: Any) -> None: + opened.append(file.get_path()) + + def launch(self, _parent: object, _cancellable: object, _callback: object) -> None: + pass + + monkeypatch.setattr(pages.Gtk, "FileLauncher", Launcher, raising=False) + monkeypatch.setattr(pages.Gtk, "check_version", lambda *_version: None) + monkeypatch.setenv("CODEX32_FORMS_DIR", "/home/amnesia/Tor Browser/codex32 forms") + view = Adw.NavigationView() + page = pages._ready_page(view, object(), 2, 3, 16) # type: ignore[arg-type] + + _button(page, "Open the recovery card form").emit("clicked") + assert opened == ["/home/amnesia/Tor Browser/codex32 forms/recovery-card.html"] diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index 2687a49..b71fa67 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -33,7 +33,7 @@ } ) CORE_ADAPTER = "codex32._bitcoin_core" -BUDGET = 2000 +BUDGET = 2050 def _package() -> Path: @@ -153,3 +153,17 @@ def test_restore_verifies_identity_before_creating_a_destination_wallet() -> Non create = job.body[1] assert isinstance(create, ast.Expr) and isinstance(create.value, ast.Call) assert isinstance(create.value.func, ast.Attribute) and create.value.func.attr == "create" + + +def test_only_the_checklist_hands_a_file_to_the_desktop() -> None: + """The one external launch opens a bundled blank form, before any seed exists.""" + tree = ast.parse((_package() / "pages.py").read_text()) + launching = { + function.name + for function in tree.body + if isinstance(function, ast.FunctionDef) + and any( + isinstance(node, ast.Attribute) and node.attr == "FileLauncher" for node in ast.walk(function) + ) + } + assert launching == {"_ready_page"} From b045908d2ab16220899022624875c08be986e076 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 10 Oct 2026 05:16:57 -0500 Subject: [PATCH 2/2] wallet: Rescan from recorded creation date Ask for the seed's approximate first-use date on restore and reject scans a pruned or validating AssumeUTXO node cannot complete before creating or importing a wallet. Validate retained-block timestamps before date formatting and describe the actual dated scan on the finished page. Have Core persist load_on_startup at creation. Return all creation warnings for display to the operator instead of inventing a separate loading or quarantine policy. Preserve structural RPC response checks and add regressions for invalid metadata and visible warnings. Validation: Core 32 regtest with physically pruned genesis recovers a confirmed synthetic payment from the recorded date, refuses timestamp zero before mutation, and reloads restored wallets from settings.json after restart. GUI and Core boundary tests pass under Xvfb; Ruff and mypy pass. Refs BenWestgate/Bails#314 --- AGENTS.md | 2 +- docs/developer/api.md | 22 +++- docs/developer/gui.md | 11 +- docs/security/invariants.md | 6 +- docs/security/model.md | 2 +- docs/user/gui.md | 13 +++ docs/user/guide.md | 5 +- src/codex32/_bitcoin_core.py | 70 ++++++++++--- src/codex32/cli.py | 23 +++-- src/codex32_gui/pages.py | 41 ++++---- src/codex32_gui/wallet_setup.py | 50 ++++++---- tests/test_bitcoin_core.py | 171 ++++++++++++++++++++++++++++++++ tests/test_cli.py | 32 +++++- tests/test_gui_boundaries.py | 2 +- tests/test_gui_restore_date.py | 94 ++++++++++++++++++ tests/test_gui_wallet_setup.py | 48 +++++++-- 16 files changed, 506 insertions(+), 86 deletions(-) create mode 100644 tests/test_gui_restore_date.py 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..2fbcbf2 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 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..6b00582 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 c813f7a..6955e97 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 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..26380b1 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 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 c367d8e..6ed8081 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 11abc2c..a9cf6d3 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 84aad2c..39bc291 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 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_boundaries.py b/tests/test_gui_boundaries.py index b71fa67..1d73c9c 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 0000000..19c95a7 --- /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 4b48081..97379a2 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)