From 9c9dea835fdd82b735e00045d483d6fdb78f40ce Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:10:21 +0200 Subject: [PATCH 1/2] fix(spv): wait for wallet bootstrap before filter scan begins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem On app start, SPV initialisation and wallet-from-DB bootstrap run as two parallel async flows. SPV's `run_spv_loop` has a built-in wait that blocks the compact-block-filter scan until `wm.wallet_count() >= expected_wallet_count`. The signal passed to that wait — `expected_wallet_count` — was computed from the in-memory wallet set, which at the moment `start_spv()` runs is empty because `bootstrap_loaded_wallets` hasn't decrypted any seeds yet. The wait loop interpreted `expected_wallet_count == 0` as "no wallets expected, don't wait", fell through to `build_client(has_wallets=false)` with `start_height = u32::MAX`, and scanned the entire filter range against an empty monitored set. Wallet transactions on-chain were never matched. Balance displayed 0 until the user manually cleared SPV data and re-synced. ## Reproduction 1. Launch DET with one or more wallets already in the local database. 2. Let SPV sync on startup without touching anything. 3. Expected: wallet balances show correctly on the main screen. 4. Actual: balances display as 0, transactions absent from the Wallet screen. ## Fix When the in-memory count is 0, consult the database via a new `Database::wallet_count_for_network()` helper. If the DB holds wallets for the active network, report at least 1 so the wait loop blocks until `bootstrap_loaded_wallets` loads the first wallet into memory. The existing `wallet_count() >= expected` condition then naturally releases as soon as Flow B's side effect lands. The choice of `.min(1)` is deliberate: on fresh installs with no DB rows, the fallback correctly reports 0 and the wait loop continues to skip (no wallets to wait for); on established installs with N wallets the fallback reports 1 rather than N so one successful wallet unlock releases the wait without being blocked by wallets that may fail to decrypt later. Part of the zero-balance fix chain tracked in #829. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/context/wallet_lifecycle.rs | 12 ++++++++ src/database/wallet.rs | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 90e5f0260..1eda3ff6e 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -69,6 +69,18 @@ impl AppContext { .count() }) .unwrap_or(0); + // Fallback: if no open wallets in memory yet but the database has + // wallets for this network, expect at least 1. + // bootstrap_loaded_wallets will load them shortly and the SPV wait + // loop will block until load completes. + let expected_wallets = if expected_wallets == 0 { + self.db + .wallet_count_for_network(&self.network) + .unwrap_or(0) + .min(1) + } else { + expected_wallets + }; // Register reconcile channel BEFORE starting SPV so the event handlers // (spawned inside run_spv_loop) always capture a valid sender. self.spv_setup_reconcile_listener(); diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 841a5221c..9392f0f65 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -92,6 +92,16 @@ impl Database { tx.commit() } + /// Count how many wallets are stored in the database for a given network. + pub fn wallet_count_for_network(&self, network: &Network) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM wallet WHERE network = ?", + [network.to_string()], + |row| row.get(0), + ) + } + /// Update the Dash Core wallet name for an HD wallet. /// /// Returns `Ok(true)` if exactly one row was updated, `Ok(false)` if no @@ -1884,4 +1894,45 @@ mod tests { .expect("Failed to query total_received"); assert_eq!(total_received, 10_000_000); } + + fn insert_test_wallet(db: &Database, seed_hash: &[u8; 32], network: Network) { + let network_str = network.to_string(); + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, ?)", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + network_str, + ], + ) + .expect("Failed to insert test wallet"); + } + + #[test] + fn test_wallet_count_for_network_counts_per_network() { + let db = create_test_database().expect("create db"); + let mut seed_a = create_test_seed_hash(); + let mut seed_b = create_test_seed_hash(); + seed_a[0] = 0xA0; + seed_b[0] = 0xB0; + + insert_test_wallet(&db, &seed_a, Network::Testnet); + insert_test_wallet(&db, &seed_b, Network::Testnet); + + assert_eq!( + db.wallet_count_for_network(&Network::Testnet) + .expect("count"), + 2 + ); + assert_eq!( + db.wallet_count_for_network(&Network::Mainnet) + .expect("count"), + 0 + ); + } } From 8c3c9aa5ca3a53556106b6b9017bfb6f7d00541d Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:19:34 +0200 Subject: [PATCH 2/2] Update src/context/wallet_lifecycle.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/context/wallet_lifecycle.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 1eda3ff6e..1cb3c850a 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -74,10 +74,7 @@ impl AppContext { // bootstrap_loaded_wallets will load them shortly and the SPV wait // loop will block until load completes. let expected_wallets = if expected_wallets == 0 { - self.db - .wallet_count_for_network(&self.network) - .unwrap_or(0) - .min(1) + self.db.wallet_count_for_network(&self.network)?.min(1) } else { expected_wallets };