From a864da575191d4cd26d15de70c992d36e62e14cb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 6 Aug 2025 08:36:48 -0400 Subject: [PATCH] Cache BIP85 derivations and incremental snapshots --- pytest.ini | 2 +- src/nostr/snapshot.py | 11 ++++++++ src/seedpass/core/manager.py | 28 +++++++++++++++++++ tests/perf/test_bip85_cache.py | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/perf/test_bip85_cache.py diff --git a/pytest.ini b/pytest.ini index 25e1f5c..70fcd4f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,7 +3,7 @@ addopts = -n auto log_cli = true log_cli_level = WARNING log_level = WARNING -testpaths = src/tests +testpaths = src/tests tests markers = network: tests that require network connectivity stress: long running stress tests diff --git a/src/nostr/snapshot.py b/src/nostr/snapshot.py index fafff42..2a6d17a 100644 --- a/src/nostr/snapshot.py +++ b/src/nostr/snapshot.py @@ -59,7 +59,18 @@ class SnapshotHandler: await self.ensure_manifest_is_current() await self._connect_async() manifest, chunks = prepare_snapshot(encrypted_bytes, limit) + + existing: dict[str, str] = {} + if self.current_manifest: + for old in self.current_manifest.chunks: + if old.hash and old.event_id: + existing[old.hash] = old.event_id + for meta, chunk in zip(manifest.chunks, chunks): + cached_id = existing.get(meta.hash) + if cached_id: + meta.event_id = cached_id + continue content = base64.b64encode(chunk).decode("utf-8") builder = nostr_client.EventBuilder( nostr_client.Kind(KIND_SNAPSHOT_CHUNK), content diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 7a0d3fc..fe6c23e 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -180,6 +180,7 @@ class PasswordManager: self.error_queue: queue.Queue[Exception] = queue.Queue() self._current_notification: Optional[Notification] = None self._notification_expiry: float = 0.0 + self._bip85_cache: dict[tuple[int, int], bytes] = {} # Track changes to trigger periodic Nostr sync self.is_dirty: bool = False @@ -212,6 +213,20 @@ class PasswordManager: self.fingerprint_manager.get_current_fingerprint_dir() ) + def get_bip85_entropy(self, purpose: int, index: int, bytes_len: int = 32) -> bytes: + """Return deterministic entropy via the cached BIP-85 function.""" + + if self.bip85 is None: + raise RuntimeError("BIP-85 is not initialized") + return self.bip85.derive_entropy( + index=index, bytes_len=bytes_len, app_no=purpose + ) + + def clear_bip85_cache(self) -> None: + """Clear the internal BIP-85 cache.""" + + self._bip85_cache.clear() + def ensure_script_checksum(self) -> None: """Initialize or verify the checksum of the manager script.""" script_path = Path(__file__).resolve() @@ -1173,7 +1188,20 @@ class PasswordManager: try: seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() self.bip85 = BIP85(seed_bytes) + self._bip85_cache = {} + orig_derive = self.bip85.derive_entropy + + def cached_derive(index: int, bytes_len: int, app_no: int = 39) -> bytes: + key = (app_no, index) + if key not in self._bip85_cache: + self._bip85_cache[key] = orig_derive( + index=index, bytes_len=bytes_len, app_no=app_no + ) + return self._bip85_cache[key] + + self.bip85.derive_entropy = cached_derive logging.debug("BIP-85 initialized successfully.") + self.clear_bip85_cache() except Exception as e: logging.error(f"Failed to initialize BIP-85: {e}", exc_info=True) print(colored(f"Error: Failed to initialize BIP-85: {e}", "red")) diff --git a/tests/perf/test_bip85_cache.py b/tests/perf/test_bip85_cache.py new file mode 100644 index 0000000..6c1af04 --- /dev/null +++ b/tests/perf/test_bip85_cache.py @@ -0,0 +1,50 @@ +import time + +from seedpass.core.manager import PasswordManager + + +class SlowBIP85: + """BIP85 stub that simulates a costly derive.""" + + def __init__(self): + self.calls = 0 + + def derive_entropy(self, index: int, bytes_len: int, app_no: int = 39) -> bytes: + self.calls += 1 + time.sleep(0.01) + return b"\x00" * bytes_len + + +def _setup_manager(bip85: SlowBIP85) -> PasswordManager: + pm = PasswordManager.__new__(PasswordManager) + pm._bip85_cache = {} + pm.bip85 = bip85 + orig = bip85.derive_entropy + + def cached(index: int, bytes_len: int, app_no: int = 39) -> bytes: + key = (app_no, index) + if key not in pm._bip85_cache: + pm._bip85_cache[key] = orig(index=index, bytes_len=bytes_len, app_no=app_no) + return pm._bip85_cache[key] + + bip85.derive_entropy = cached + return pm + + +def test_bip85_cache_benchmark(): + slow_uncached = SlowBIP85() + start = time.perf_counter() + for _ in range(3): + slow_uncached.derive_entropy(1, 32, 32) + uncached_time = time.perf_counter() - start + + slow_cached = SlowBIP85() + pm = _setup_manager(slow_cached) + start = time.perf_counter() + for _ in range(3): + pm.get_bip85_entropy(32, 1) + cached_time = time.perf_counter() - start + + assert cached_time < uncached_time + assert slow_uncached.calls == 3 + assert slow_cached.calls == 1