Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions dash-spv/src/client/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,35 @@ impl<W: WalletInterface, N: NetworkManager, S: StorageManager> DashSpvClient<W,
// The wallet-derived height is floored at the network minimum: no HD/BIP39 wallet
// can predate mainnet's activation height, so a low or zero birth height must never
// drag mainnet sync below it.
let start_from_height = match config.start_from_height {
Some(height) => 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.
Expand Down
154 changes: 153 additions & 1 deletion dash-spv/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WalletManager<ManagedWalletInfo>, MockNetworkManager, DiskStorageManager>;

async fn client_at(
network: Network,
birth_height: u32,
mnemonic: &str,
storage_path: &Path,
) -> TestClient {
let mut wallet_manager = WalletManager::<ManagedWalletInfo>::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<Vec<u8>> {
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
Expand Down Expand Up @@ -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()
Expand Down
Loading