From cfb4272e397afef5cccd2011c5e9ec6149553557 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 4 Nov 2026 14:27:25 +0000 Subject: [PATCH 1/3] wallet: Date a new wallet by this computer's clock Bitcoin Core resolves an import timestamp of "now" to its tip's median time. On an offline signer that never synced, that is the genesis block, so every new wallet was born in 2009, and the watch-only copy Bails exports from it asks the online node to rescan from genesis, which a pruned node cannot do. A new wallet now takes this computer's clock, a day early like a recorded creation date, so a clock running up to a day fast cannot skip its first payments. Restores keep the recorded date. On a Core 32.1 regtest signer at genesis, the descriptors were dated 1296688602 (genesis) before and the clock less a day after; the signer still found a payment from blocks it synced later. Claude-Session: https://claude.ai/code/session_01N8jLK61uohaW6W7Y8AHnMb --- src/codex32/_bitcoin_core.py | 6 +++++- tests/test_bitcoin_core.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index b2689ea5..99525610 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -449,12 +449,16 @@ def initialize( continue # Checked last, after any wait above: a pruning node keeps pruning while it waits. self.check_history(timestamp) + # Core's "now" is its tip's time: the genesis block's on an offline signer that never + # synced, and watch-only exports carry it. A new wallet takes this computer's clock, + # a day early like a record's date, so a fast clock cannot skip its first payments. + start = max(0, int(time.time()) - 86400) if timestamp == "now" else timestamp records = core_descriptors( secret, account=account, testnet=self.chain != "main", private=True, - timestamp=timestamp, + timestamp=start, ) imported = self._rpc( "importdescriptors", diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index f0ecee7e..557f7962 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -391,6 +391,7 @@ def test_encrypted_import_retries_without_a_passphrase_verifies_and_relocks( "_rpc", lambda client, *args, wallet=None, stdin=None: rpc(client, *args, wallet=wallet, stdin=stdin), ) + monkeypatch.setattr("codex32._bitcoin_core.time.time", lambda: 1_700_000_000.5) client = BitcoinCore("bitcoin-cli", "main", 300000) messages: list[str] = [] delays: list[int] = [] @@ -411,7 +412,8 @@ def unlock(seconds: int) -> None: assert arguments == ("importdescriptors",) and wallet == "signer" assert private_stdin is not None and private_stdin.endswith("\n") records = json.loads(private_stdin) - assert len(records) == 4 and all(record["timestamp"] == "now" for record in records) + # A new wallet's "now" is this computer's clock less a day, not Core's tip (genesis offline). + assert len(records) == 4 and all(record["timestamp"] == 1_700_000_000 - 86400 for record in records) assert all("xprv" in record["desc"] for record in records) assert all("xprv" not in " ".join((*args, selected or "")) for args, selected, _data in rpc.calls) assert sum(args[:2] == ("-named", "derivehdkey") for args, _wallet, _stdin in rpc.calls) == 4 From ae9d360a74020b00e5d0dcf0703fa7e9fce6de50 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 4 Nov 2026 18:05:16 +0000 Subject: [PATCH 2/3] wallet: Keep the new-wallet timestamp within the size budget Claude-Session: https://claude.ai/code/session_01N8jLK61uohaW6W7Y8AHnMb --- src/codex32/_bitcoin_core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 99525610..b1eae17c 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -452,13 +452,12 @@ def initialize( # Core's "now" is its tip's time: the genesis block's on an offline signer that never # synced, and watch-only exports carry it. A new wallet takes this computer's clock, # a day early like a record's date, so a fast clock cannot skip its first payments. - start = max(0, int(time.time()) - 86400) if timestamp == "now" else timestamp records = core_descriptors( secret, account=account, testnet=self.chain != "main", private=True, - timestamp=start, + timestamp=max(0, int(time.time()) - 86400) if timestamp == "now" else timestamp, ) imported = self._rpc( "importdescriptors", From 6bcbfa042406403b5e66f25528330d872599d827 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 10 Nov 2026 17:32:36 -0600 Subject: [PATCH 3/3] gui: Grow the window so no page needs scrolling (#223) Every page now asks the window to grow, never past the screen's height, whenever its content is taller than the space it has: on arrival, when a section opens, and when a message gets longer. A window the operator shrinks grows back to fit. The scroller stays as the last resort on a screen too short for the page. Name-and-value rows (wallet identity, wallet record, new card) are one line each, value on the right, so the finished page fits the default 880x620 window and reads straight down against the paper record. The GUI size budget rises by the 13 lines this takes. Claude-Session: https://claude.ai/code/session_01N8jLK9eptxLjUcNnMk1VGr --- src/codex32_gui/pages.py | 18 +++++++++++++++--- src/codex32_gui/style.py | 5 +++++ tests/test_gui_boundaries.py | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 6ed80813..4dbccb95 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, Gio, GLib, Gtk +from gi.repository import Adw, Gio, GLib, Gtk, Pango from codex32 import ( ConfirmationResult, @@ -111,6 +111,7 @@ def _page( ) -> Adw.NavigationPage: scroller = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) scroller.set_child(Adw.Clamp(maximum_size=700, child=content, margin_start=18, margin_end=18)) + scroller.get_vadjustment().connect("changed", lambda shown: _grow(scroller, shown)) bars = Adw.ToolbarView() bars.add_top_bar(Adw.HeaderBar()) bars.set_content(scroller) @@ -121,6 +122,15 @@ def _page( return page +def _grow(scroller: Gtk.ScrolledWindow, shown: Gtk.Adjustment) -> None: + """Make the window taller, never past the screen, until the page needs no scrolling.""" + window, short = scroller.get_root(), shown.get_upper() - shown.get_page_size() + if short > 0 and isinstance(window, Gtk.Window) and (surface := window.get_surface()) is not None: + width, height = window.get_default_size() + screen = window.get_display().get_monitor_at_surface(surface).get_geometry().height + window.set_default_size(width, min(height + int(short) + 1, screen)) + + def _column(*children: Gtk.Widget) -> Gtk.Box: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14, margin_top=18, margin_bottom=18) for child in children: @@ -215,9 +225,11 @@ def _blank(flow: Gtk.FlowBox) -> None: def _rows(title: str, values: Sequence[tuple[str, str]]) -> Adw.PreferencesGroup: - group = Adw.PreferencesGroup(title=title) + group = Adw.PreferencesGroup(title=title, css_classes=["compact"]) for label, value in values: - group.add(Adw.ActionRow(title=label, subtitle=value, subtitle_selectable=True, use_markup=False)) + row = Adw.ActionRow(title=label, use_markup=False) + row.add_suffix(Gtk.Label(label=value, selectable=True, wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR)) + group.add(row) return group diff --git a/src/codex32_gui/style.py b/src/codex32_gui/style.py index 2bc7879b..6c3cbca2 100644 --- a/src/codex32_gui/style.py +++ b/src/codex32_gui/style.py @@ -27,6 +27,11 @@ border-radius: 12px; padding: 14px; } +.compact row > box.header { + min-height: 0; + padding-top: 0; + padding-bottom: 0; +} .reading { font-size: 0.9rem; } diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index 1d73c9c7..e2ada80c 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -33,7 +33,7 @@ } ) CORE_ADAPTER = "codex32._bitcoin_core" -BUDGET = 2050 +BUDGET = 2063 def _package() -> Path: