From 2a067e917b668c78a579af69533ba0c835fc3830 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 21 Jul 2026 07:55:12 +0200 Subject: [PATCH] fix(dash-spv): re-anchor incompatible shared storage --- dash-spv/src/client/lifecycle.rs | 29 +++++- dash-spv/src/client/mod.rs | 154 ++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 5 deletions(-) diff --git a/dash-spv/src/client/lifecycle.rs b/dash-spv/src/client/lifecycle.rs index 4ce3d0c47..bdeadc97a 100644 --- a/dash-spv/src/client/lifecycle.rs +++ b/dash-spv/src/client/lifecycle.rs @@ -52,14 +52,35 @@ impl DashSpvClient Some(height), + let required_start_height = match config.start_from_height { + Some(height) => height, None => { let birth_height = wallet.read().await.earliest_required_height().await; - let start = birth_height.max(config.network.hd_wallet_sync_floor()); - (start > 0).then_some(start) + birth_height.max(config.network.hd_wallet_sync_floor()) } }; + let start_from_height = (required_start_height > 0).then_some(required_start_height); + + // SPV storage is network-scoped rather than wallet-scoped. A wallet created near + // the tip can therefore leave a checkpoint-anchored store behind after it is + // removed. If an older wallet is imported later, that store cannot serve the + // wallet's required history because headers cannot be scanned below its start. + // + // Clear the whole store before constructing any managers so headers, filter + // headers, filters, blocks, metadata, and masternode state are re-anchored + // coherently. Stores that already begin at or before the required height remain + // valid and are preserved. + if let Some(stored_start_height) = storage.get_start_height().await { + if stored_start_height > required_start_height { + tracing::warn!( + network = ?config.network, + stored_start_height, + required_start_height, + "Persisted SPV store starts after wallet-required height; clearing and re-anchoring" + ); + storage.clear().await.map_err(SpvError::Storage)?; + } + } // Initialize genesis block or checkpoint before creating managers, // so they can read the tip from storage during construction. diff --git a/dash-spv/src/client/mod.rs b/dash-spv/src/client/mod.rs index 6a7c8bda5..6805c97d9 100644 --- a/dash-spv/src/client/mod.rs +++ b/dash-spv/src/client/mod.rs @@ -40,18 +40,94 @@ mod config_test; mod tests { use super::{ClientConfig, DashSpvClient}; use crate::client::config::MempoolStrategy; - use crate::storage::DiskStorageManager; + use crate::storage::{BlockHeaderStorage, DiskStorageManager, MetadataStorage, StorageManager}; use crate::test_utils::MockNetworkManager; use crate::Network; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet_manager::WalletManager; + use std::path::Path; use std::sync::Arc; use tempfile::TempDir; use tokio::sync::RwLock; const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const SECOND_TEST_MNEMONIC: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + const STORAGE_MARKER_KEY: &str = "spv_cache_reanchor_test_marker"; + const STORAGE_MARKER_VALUE: &[u8] = b"preserve-compatible-cache"; + + type TestClient = + DashSpvClient, MockNetworkManager, DiskStorageManager>; + + async fn client_at( + network: Network, + birth_height: u32, + mnemonic: &str, + storage_path: &Path, + ) -> TestClient { + let mut wallet_manager = WalletManager::::new(network); + wallet_manager + .create_wallet_from_mnemonic( + mnemonic, + birth_height, + WalletAccountCreationOptions::Default, + ) + .expect("wallet creation must succeed"); + let wallet = Arc::new(RwLock::new(wallet_manager)); + + let config = match network { + Network::Mainnet => ClientConfig::mainnet(), + Network::Testnet => ClientConfig::testnet(), + other => panic!("unsupported test network: {other:?}"), + } + .without_filters() + .without_masternodes() + .with_storage_path(storage_path); + + let storage = DiskStorageManager::new(&config).await.expect("Failed to create storage"); + DashSpvClient::new(config, MockNetworkManager::new(), storage, wallet, vec![Arc::new(())]) + .await + .expect("client construction must succeed") + } + + async fn storage_start(client: &TestClient) -> u32 { + client + .storage() + .lock() + .await + .get_start_height() + .await + .expect("client storage must have an anchor") + } + + async fn store_marker(client: &TestClient) { + let metadata = client.storage().lock().await.metadata(); + metadata + .write() + .await + .store_metadata(STORAGE_MARKER_KEY, STORAGE_MARKER_VALUE) + .await + .expect("test marker must be stored"); + } + + async fn load_marker(client: &TestClient) -> Option> { + let metadata = client.storage().lock().await.metadata(); + let value = metadata + .read() + .await + .load_metadata(STORAGE_MARKER_KEY) + .await + .expect("test marker read must succeed"); + value + } + + async fn shutdown_client(client: TestClient) { + let storage = client.storage(); + storage.lock().await.shutdown().await; + drop(client); + } /// Construct a mainnet client with the given wallet birth height and optional /// explicit `start_from_height`, then return the height and hash its chain is @@ -115,6 +191,82 @@ mod tests { assert_eq!(anchored_tip(120_000, Some(60_000)).await.0, 50_000); } + #[tokio::test] + async fn older_testnet_wallet_reanchors_recent_shared_storage_to_genesis() { + let temp_dir = TempDir::new().unwrap(); + + let recent_wallet = + client_at(Network::Testnet, 1_510_000, TEST_MNEMONIC, temp_dir.path()).await; + assert_eq!(storage_start(&recent_wallet).await, 1_500_000); + store_marker(&recent_wallet).await; + shutdown_client(recent_wallet).await; + + let old_wallet = + client_at(Network::Testnet, 0, SECOND_TEST_MNEMONIC, temp_dir.path()).await; + assert_eq!(storage_start(&old_wallet).await, 0); + assert_eq!( + load_marker(&old_wallet).await, + None, + "re-anchoring must clear the complete SPV store, including metadata" + ); + shutdown_client(old_wallet).await; + } + + #[tokio::test] + async fn storage_that_starts_before_wallet_requirement_is_preserved() { + let earlier_store_dir = TempDir::new().unwrap(); + + let old_wallet = + client_at(Network::Testnet, 0, TEST_MNEMONIC, earlier_store_dir.path()).await; + assert_eq!(storage_start(&old_wallet).await, 0); + store_marker(&old_wallet).await; + shutdown_client(old_wallet).await; + + let recent_wallet = + client_at(Network::Testnet, 1_510_000, SECOND_TEST_MNEMONIC, earlier_store_dir.path()) + .await; + assert_eq!(storage_start(&recent_wallet).await, 0); + assert_eq!( + load_marker(&recent_wallet).await, + Some(STORAGE_MARKER_VALUE.to_vec()), + "storage with sufficient historical coverage must be reused" + ); + shutdown_client(recent_wallet).await; + + let equal_store_dir = TempDir::new().unwrap(); + let first_recent_wallet = + client_at(Network::Testnet, 1_500_000, TEST_MNEMONIC, equal_store_dir.path()).await; + assert_eq!(storage_start(&first_recent_wallet).await, 1_500_000); + store_marker(&first_recent_wallet).await; + shutdown_client(first_recent_wallet).await; + + let second_recent_wallet = + client_at(Network::Testnet, 1_500_000, SECOND_TEST_MNEMONIC, equal_store_dir.path()) + .await; + assert_eq!(storage_start(&second_recent_wallet).await, 1_500_000); + assert_eq!( + load_marker(&second_recent_wallet).await, + Some(STORAGE_MARKER_VALUE.to_vec()), + "storage anchored exactly at the wallet requirement must be reused" + ); + shutdown_client(second_recent_wallet).await; + } + + #[tokio::test] + async fn mainnet_reanchor_respects_hd_wallet_sync_floor() { + let temp_dir = TempDir::new().unwrap(); + + let recent_wallet = + client_at(Network::Mainnet, 560_000, TEST_MNEMONIC, temp_dir.path()).await; + assert_eq!(storage_start(&recent_wallet).await, 550_000); + shutdown_client(recent_wallet).await; + + let old_wallet = + client_at(Network::Mainnet, 0, SECOND_TEST_MNEMONIC, temp_dir.path()).await; + assert_eq!(storage_start(&old_wallet).await, 200_000); + shutdown_client(old_wallet).await; + } + #[tokio::test] async fn client_exposes_shared_wallet_manager() { let config = ClientConfig::mainnet()