diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9926d30..3515882 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -392,6 +392,12 @@ jobs: - name: Run tests (stratum-v2 feature, lib) run: cargo test --features stratum-v2 --lib + # `rocksdb` is not in default features but gates Bitcoin Core import / block-file + # reuse (src/storage/bitcoin_core_*, core_reuse_tests, tests/bitcoin_core_tests.rs, + # tests/core_drop_in_integration.rs). Without this step that code is never tested. + - name: Run tests (rocksdb feature) + run: cargo test --features rocksdb + - name: Dump disk usage on failure if: failure() run: | diff --git a/src/storage/bitcoin_core_blocks.rs b/src/storage/bitcoin_core_blocks.rs index 84b9162..ec4de7c 100644 --- a/src/storage/bitcoin_core_blocks.rs +++ b/src/storage/bitcoin_core_blocks.rs @@ -183,6 +183,11 @@ impl BitcoinCoreBlockReader { }) } + /// Network whose block magic this reader expects. + pub fn network(&self) -> CoreDataNetwork { + self.network + } + /// Get the magic bytes for the network fn get_magic(&self) -> &[u8; 4] { network_magic(self.network) diff --git a/src/storage/bitcoin_detection.rs b/src/storage/bitcoin_detection.rs index 4f5a627..fc39381 100644 --- a/src/storage/bitcoin_detection.rs +++ b/src/storage/bitcoin_detection.rs @@ -125,9 +125,16 @@ impl BitcoinCoreDetection { true } - /// Check if directory contains Bitcoin Core data (alias for [`is_core_layout_at`]). - fn is_bitcoin_core_dir(dir: &Path, _network: CoreDataNetwork) -> bool { - Self::is_core_layout_at(dir) + /// Check if `base_dir` contains Bitcoin Core data for the given network. + /// + /// Mainnet data lives at the root of `base_dir`. Other networks use a + /// network-specific subdirectory: `testnet3/`, `testnet4/`, `signet/`, `regtest/`. + pub fn is_bitcoin_core_dir(base_dir: &Path, network: CoreDataNetwork) -> bool { + let data_dir = match network { + CoreDataNetwork::Mainnet => base_dir.to_path_buf(), + other => base_dir.join(other.directory_name()), + }; + Self::is_core_layout_at(&data_dir) } /// Detect database format (LevelDB) @@ -160,40 +167,156 @@ impl BitcoinCoreDetection { /// Detect network from data directory /// - /// Attempts to detect the network by checking directory structure - /// and configuration files. + /// Attempts to detect the network by checking directory structure. + /// Returns `None` if the network cannot be determined (caller must decide + /// what to do—we do NOT silently fall back to mainnet). pub fn detect_network(data_dir: &Path) -> Option { - // Check directory name - if let Some(dir_name) = data_dir.file_name().and_then(|n| n.to_str()) { - match dir_name { - "testnet3" => return Some(CoreDataNetwork::Testnet), - "testnet4" => return Some(CoreDataNetwork::Testnet4), - "regtest" => return Some(CoreDataNetwork::Regtest), - "signet" => return Some(CoreDataNetwork::Signet), - _ => {} - } + let dir_name = data_dir.file_name().and_then(|n| n.to_str())?; + + // Check well-known network subdirectory names + match dir_name { + "testnet3" => return Some(CoreDataNetwork::Testnet), + "testnet4" => return Some(CoreDataNetwork::Testnet4), + "regtest" => return Some(CoreDataNetwork::Regtest), + "signet" => return Some(CoreDataNetwork::Signet), + _ => {} } - // Check parent directory + // Detect custom signet directories (signet_). + // These are valid Bitcoin Core signet data directories but we cannot + // auto-detect which signet network they belong to. Return None so the + // caller can require explicit network selection. + if Self::is_custom_signet_dir_name(dir_name) { + return None; + } + + // If the directory is ".bitcoin" or "bitcoind" (common base names), it's mainnet + if dir_name == ".bitcoin" || dir_name == "bitcoind" { + return Some(CoreDataNetwork::Mainnet); + } + + // Check parent directory for additional context if let Some(parent) = data_dir.parent() { if let Some(parent_name) = parent.file_name().and_then(|n| n.to_str()) { + // If parent is .bitcoin, the given path is a network subdir if parent_name == ".bitcoin" { - // Check if we're in a subdirectory - if let Some(dir_name) = data_dir.file_name().and_then(|n| n.to_str()) { - match dir_name { - "testnet3" => return Some(CoreDataNetwork::Testnet), - "testnet4" => return Some(CoreDataNetwork::Testnet4), - "regtest" => return Some(CoreDataNetwork::Regtest), - "signet" => return Some(CoreDataNetwork::Signet), - _ => return Some(CoreDataNetwork::Mainnet), - } - } + // Already checked known names above; if we're here, it's unrecognized + return None; } } } - // Default to mainnet if we can't determine - Some(CoreDataNetwork::Mainnet) + // Cannot determine network from directory structure + None + } + + /// Detect network from data directory, returning an error for ambiguous cases. + /// + /// Unlike [`Self::detect_network`], this method returns an explicit error when the + /// directory appears to be a custom signet (`signet_`) rather than + /// silently returning `None`. Use this when you need a clear error message. + pub fn detect_network_strict(data_dir: &Path) -> Result { + let dir_name = data_dir + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid data directory path"))?; + + // Detect custom signet directories and reject with a clear error + if Self::is_custom_signet_dir_name(dir_name) { + return Err(Self::custom_signet_error(dir_name)); + } + + Self::detect_network(data_dir).ok_or_else(|| { + anyhow::anyhow!( + "Cannot determine network from directory '{}': it is not a standard \ + Bitcoin Core network folder (.bitcoin, bitcoind, testnet3, testnet4, \ + signet, regtest).", + dir_name + ) + }) + } + + /// True for Bitcoin Core custom signet folders (`signet_`). + fn is_custom_signet_dir_name(dir_name: &str) -> bool { + dir_name.starts_with("signet_") + } + + fn custom_signet_error(dir_name: &str) -> anyhow::Error { + anyhow::anyhow!( + "Custom signet directory detected: '{}'. Its block files use a \ + challenge-specific network magic, so BLVM cannot read them as the standard \ + signet. Core block reuse does not support custom signets; set \ + storage.reuse_core_block_files = false to run without reusing Core's block files.", + dir_name + ) + } + + /// Decide which network's block files to read when reusing a Core datadir in place. + /// + /// Three sources can name the network, in this priority order: + /// 1. `marker_network`: the `network` recorded in the BLVM migration marker + /// (`blvm_meta/migration.json`). An empty string counts as absent. + /// 2. The Core datadir folder name, via [`Self::detect_network`]. + /// 3. `node_network`: the network the node itself is configured to run. + /// + /// Every source that is known must agree; the first known one is returned. + /// Returns an error (caller must skip reuse) when: + /// - `core_dir` is a custom signet folder (`signet_`), whatever the + /// other sources say: its block magic is not the standard signet magic; + /// - the marker names a network BLVM does not know; + /// - two known sources disagree; + /// - no source is known. + pub fn resolve_reuse_network( + core_dir: &Path, + marker_network: Option<&str>, + node_network: Option, + ) -> Result { + if let Some(dir_name) = core_dir.file_name().and_then(|n| n.to_str()) { + if Self::is_custom_signet_dir_name(dir_name) { + return Err(Self::custom_signet_error(dir_name)); + } + } + + let marker = match marker_network.map(str::trim).filter(|s| !s.is_empty()) { + Some(s) => Some(s.parse::().map_err(|e| { + anyhow::anyhow!("Migration marker network '{s}' is not usable: {e}") + })?), + None => None, + }; + let folder = Self::detect_network(core_dir); + + let sources = [ + ("migration marker", marker), + ("Core datadir folder name", folder), + ("node network", node_network), + ]; + let mut chosen: Option<(&str, CoreDataNetwork)> = None; + for (label, network) in sources { + let Some(network) = network else { continue }; + match chosen { + None => chosen = Some((label, network)), + Some((first_label, first)) if first != network => { + return Err(anyhow::anyhow!( + "Network mismatch for Core datadir {:?}: {} says {} but {} says {}", + core_dir, + first_label, + first, + label, + network + )); + } + Some(_) => {} + } + } + + chosen.map(|(_, network)| network).ok_or_else(|| { + anyhow::anyhow!( + "Cannot determine network for Core datadir {:?}: the migration marker \ + records none, the folder name is not a standard Core network folder, \ + and no node network was supplied.", + core_dir + ) + }) } /// Verify database integrity @@ -248,6 +371,14 @@ mod tests { use super::*; use tempfile::TempDir; + fn create_core_layout(dir: &Path) { + let chainstate = dir.join("chainstate"); + std::fs::create_dir_all(&chainstate).unwrap(); + std::fs::write(chainstate.join("CURRENT"), "MANIFEST-000001\n").unwrap(); + std::fs::write(chainstate.join("MANIFEST-000001"), b"").unwrap(); + std::fs::create_dir_all(dir.join("blocks")).unwrap(); + } + #[test] fn test_detect_network_from_path() { let temp = TempDir::new().unwrap(); @@ -285,6 +416,62 @@ mod tests { ); } + #[test] + fn test_detect_network_signet() { + let temp = TempDir::new().unwrap(); + let signet_path = temp.path().join("signet"); + std::fs::create_dir_all(&signet_path).unwrap(); + + assert_eq!( + BitcoinCoreDetection::detect_network(&signet_path), + Some(CoreDataNetwork::Signet) + ); + } + + #[test] + fn test_detect_network_custom_signet_returns_none() { + let temp = TempDir::new().unwrap(); + let custom_signet = temp.path().join("signet_abc123def456"); + std::fs::create_dir_all(&custom_signet).unwrap(); + + assert_eq!(BitcoinCoreDetection::detect_network(&custom_signet), None); + } + + #[test] + fn test_detect_network_strict_custom_signet_errors() { + let temp = TempDir::new().unwrap(); + let custom_signet = temp.path().join("signet_abc123def456"); + std::fs::create_dir_all(&custom_signet).unwrap(); + + let result = BitcoinCoreDetection::detect_network_strict(&custom_signet); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Custom signet directory detected")); + assert!(err.contains("signet_abc123def456")); + } + + #[test] + fn test_detect_network_mainnet_base_dir() { + let temp = TempDir::new().unwrap(); + let bitcoin_dir = temp.path().join(".bitcoin"); + std::fs::create_dir_all(&bitcoin_dir).unwrap(); + + assert_eq!( + BitcoinCoreDetection::detect_network(&bitcoin_dir), + Some(CoreDataNetwork::Mainnet) + ); + } + + #[test] + fn test_detect_network_unknown_subdir_returns_none() { + let temp = TempDir::new().unwrap(); + let bitcoin_dir = temp.path().join(".bitcoin"); + let unknown = bitcoin_dir.join("unknown_network"); + std::fs::create_dir_all(&unknown).unwrap(); + + assert_eq!(BitcoinCoreDetection::detect_network(&unknown), None); + } + #[test] fn test_core_data_network_display_roundtrip() { for network in [ @@ -317,4 +504,248 @@ mod tests { let paths = BitcoinCoreDetection::get_standard_paths(CoreDataNetwork::Mainnet); assert!(!paths.is_empty()); } + + #[test] + fn test_is_bitcoin_core_dir_mainnet() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + create_core_layout(base); + + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Mainnet + )); + assert!(!BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Testnet + )); + } + + #[test] + fn test_is_bitcoin_core_dir_testnet3() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + let testnet_dir = base.join("testnet3"); + create_core_layout(&testnet_dir); + + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Testnet + )); + assert!(!BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Mainnet + )); + } + + #[test] + fn test_is_bitcoin_core_dir_testnet4() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + let testnet4_dir = base.join("testnet4"); + create_core_layout(&testnet4_dir); + + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Testnet4 + )); + assert!(!BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Mainnet + )); + assert!(!BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Testnet + )); + } + + #[test] + fn test_is_bitcoin_core_dir_signet() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + let signet_dir = base.join("signet"); + create_core_layout(&signet_dir); + + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Signet + )); + assert!(!BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Mainnet + )); + } + + #[test] + fn test_is_bitcoin_core_dir_regtest() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + let regtest_dir = base.join("regtest"); + create_core_layout(®test_dir); + + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Regtest + )); + assert!(!BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Mainnet + )); + } + + #[test] + fn test_is_bitcoin_core_dir_multiple_networks() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + create_core_layout(base); + create_core_layout(&base.join("testnet3")); + create_core_layout(&base.join("testnet4")); + + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Mainnet + )); + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Testnet + )); + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Testnet4 + )); + assert!(!BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Signet + )); + } + + #[test] + fn test_core_data_network_directory_names() { + assert_eq!(CoreDataNetwork::Mainnet.directory_name(), ""); + assert_eq!(CoreDataNetwork::Testnet.directory_name(), "testnet3"); + assert_eq!(CoreDataNetwork::Testnet4.directory_name(), "testnet4"); + assert_eq!(CoreDataNetwork::Signet.directory_name(), "signet"); + assert_eq!(CoreDataNetwork::Regtest.directory_name(), "regtest"); + } + + // ---- resolve_reuse_network: marker, then folder name, then node network ---- + + fn resolve( + dir: &Path, + marker: Option<&str>, + node: Option, + ) -> Result { + BitcoinCoreDetection::resolve_reuse_network(dir, marker, node) + } + + #[test] + fn resolve_nonstandard_mainnet_names_use_node_network() { + let temp = TempDir::new().unwrap(); + for name in ["bitcoin", "Bitcoin", "btc-data"] { + let dir = temp.path().join(name); + assert_eq!( + resolve(&dir, None, Some(CoreDataNetwork::Mainnet)).unwrap(), + CoreDataNetwork::Mainnet, + "{name} with node network mainnet" + ); + } + let srv = temp.path().join("srv").join("btc-data"); + assert_eq!( + resolve(&srv, None, Some(CoreDataNetwork::Mainnet)).unwrap(), + CoreDataNetwork::Mainnet + ); + } + + #[test] + fn resolve_without_any_source_refuses_instead_of_defaulting_to_mainnet() { + let temp = TempDir::new().unwrap(); + let err = resolve(&temp.path().join("bitcoin"), None, None).unwrap_err(); + assert!( + err.to_string().contains("Cannot determine network"), + "{err}" + ); + // An empty marker network counts as absent, not as a source. + assert!(resolve(&temp.path().join("bitcoin"), Some(""), None).is_err()); + } + + #[test] + fn resolve_priority_marker_then_folder_then_node() { + let temp = TempDir::new().unwrap(); + let odd = temp.path().join("chain-data"); + assert_eq!( + resolve(&odd, Some("testnet4"), None).unwrap(), + CoreDataNetwork::Testnet4 + ); + let t3 = temp.path().join(".bitcoin").join("testnet3"); + assert_eq!(resolve(&t3, None, None).unwrap(), CoreDataNetwork::Testnet); + assert_eq!( + resolve(&t3, Some("testnet"), Some(CoreDataNetwork::Testnet)).unwrap(), + CoreDataNetwork::Testnet + ); + } + + #[test] + fn resolve_marker_folder_disagreement_refuses() { + let temp = TempDir::new().unwrap(); + // S3: a mainnet marker on .bitcoin/signet must not open a mainnet reader. + let signet = temp.path().join(".bitcoin").join("signet"); + let err = resolve(&signet, Some("mainnet"), None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Network mismatch"), "{msg}"); + assert!(msg.contains("migration marker says mainnet"), "{msg}"); + assert!(msg.contains("folder name says signet"), "{msg}"); + let t4 = temp.path().join("testnet4"); + assert!(resolve(&t4, Some("testnet"), None).is_err()); + } + + #[test] + fn resolve_node_disagreement_refuses() { + let temp = TempDir::new().unwrap(); + let t3 = temp.path().join(".bitcoin").join("testnet3"); + assert!(resolve(&t3, None, Some(CoreDataNetwork::Mainnet)).is_err()); + let odd = temp.path().join("bitcoin"); + assert!(resolve(&odd, Some("regtest"), Some(CoreDataNetwork::Mainnet)).is_err()); + let dotbitcoin = temp.path().join(".bitcoin"); + assert!(resolve(&dotbitcoin, None, Some(CoreDataNetwork::Regtest)).is_err()); + } + + #[test] + fn resolve_custom_signet_refuses_whatever_the_other_sources_say() { + let temp = TempDir::new().unwrap(); + let custom = temp.path().join("signet_0f9188f13cb7b2c71f2a335e3a4fc328"); + for (marker, node) in [ + (None, None), + (Some("signet"), None), + (None, Some(CoreDataNetwork::Signet)), + (Some("signet"), Some(CoreDataNetwork::Signet)), + ] { + let err = resolve(&custom, marker, node).unwrap_err(); + assert!( + err.to_string().contains("Custom signet directory detected"), + "marker={marker:?} node={node:?}: {err}" + ); + } + } + + #[test] + fn resolve_unknown_marker_network_refuses() { + let temp = TempDir::new().unwrap(); + let dir = temp.path().join("bitcoin"); + let err = resolve(&dir, Some("liquid"), Some(CoreDataNetwork::Mainnet)).unwrap_err(); + assert!(err.to_string().contains("liquid"), "{err}"); + } + + #[test] + fn custom_signet_error_names_the_real_setting() { + let temp = TempDir::new().unwrap(); + let custom = temp.path().join("signet_abc123"); + for err in [ + BitcoinCoreDetection::detect_network_strict(&custom).unwrap_err(), + resolve(&custom, None, Some(CoreDataNetwork::Signet)).unwrap_err(), + ] { + let msg = err.to_string(); + assert!(msg.contains("storage.reuse_core_block_files"), "{msg}"); + assert!(!msg.contains("--network"), "{msg}"); + } + } } diff --git a/src/storage/blockstore.rs b/src/storage/blockstore.rs index a8760b8..28bb457 100644 --- a/src/storage/blockstore.rs +++ b/src/storage/blockstore.rs @@ -115,6 +115,14 @@ impl Clone for BlockStore { } impl BlockStore { + /// Network of the attached Bitcoin Core block-file reader, if any (rocksdb only). + #[cfg(feature = "rocksdb")] + pub fn bitcoin_core_reader_network( + &self, + ) -> Option { + self.bitcoin_core_reader.as_ref().map(|r| r.network()) + } + /// Create a new block store pub fn new(db: Arc) -> Result { Self::new_with_compression( diff --git a/src/storage/core_reuse_tests.rs b/src/storage/core_reuse_tests.rs new file mode 100644 index 0000000..22adb7f --- /dev/null +++ b/src/storage/core_reuse_tests.rs @@ -0,0 +1,206 @@ +//! Core block-file reuse: which network's reader `open_core_block_reader_for_store` +//! opens. Lives in the crate because that function is `pub(crate)`. +//! +//! Network rule (see `BitcoinCoreDetection::resolve_reuse_network`): migration +//! marker, then Core folder name, then the node's network; every known source +//! must agree, and custom signet (`signet_`) folders are always refused. + +use super::Storage; +use super::bitcoin_core_migrate::{MigrationMarker, migration_marker_path}; +use super::bitcoin_detection::CoreDataNetwork; +use crate::config::StorageConfig; +use blvm_protocol::types::Network; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn create_core_layout(dir: &Path) { + let chainstate = dir.join("chainstate"); + std::fs::create_dir_all(&chainstate).unwrap(); + std::fs::write(chainstate.join("CURRENT"), "MANIFEST-000001\n").unwrap(); + std::fs::write(chainstate.join("MANIFEST-000001"), b"").unwrap(); + std::fs::create_dir_all(dir.join("blocks")).unwrap(); +} + +/// A Core datadir at `temp/` plus an empty BLVM store next to it. +fn setup(temp: &TempDir, rel: &str) -> (PathBuf, PathBuf) { + let core = temp.path().join(rel); + create_core_layout(&core); + let store = temp.path().join("blvm_store"); + std::fs::create_dir_all(&store).unwrap(); + (core, store) +} + +fn write_marker(store: &Path, core: &Path, network: &str) { + let marker = MigrationMarker { + source: core.to_path_buf(), + destination: store.to_path_buf(), + network: network.to_string(), + tip_hash: String::new(), + height: 0, + muhash: None, + reuse_core_blocks: Some(true), + migrated_at: "0".to_string(), + }; + let path = migration_marker_path(store); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, serde_json::to_string(&marker).unwrap()).unwrap(); +} + +fn reuse_on() -> StorageConfig { + StorageConfig { + reuse_core_block_files: true, + ..Default::default() + } +} + +fn open(store: &Path, core: &Path, node: Option) -> Option { + let config = reuse_on(); + Storage::open_core_block_reader_for_store(store, Some(core), Some(&config), node) + .map(|r| r.network()) +} + +#[test] +fn custom_signet_core_dir_does_not_produce_a_reader() { + // Moved from tests/bitcoin_core_tests.rs (it called a pub(crate) fn, E0624). + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, "signet_abc123def456"); + assert_eq!(open(&store, &core, None), None); + // Even with the node on (standard) signet: the custom signet magic differs. + assert_eq!(open(&store, &core, Some(Network::Signet)), None); +} + +#[test] +fn nonstandard_mainnet_names_reuse_with_node_network_mainnet() { + // B2: Core's default mainnet folders on macOS/Windows ("Bitcoin"), a plain + // "bitcoin", and arbitrary paths like /srv/btc-data must keep block reuse. + for rel in ["bitcoin", "Bitcoin", "srv/btc-data"] { + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, rel); + assert_eq!( + open(&store, &core, Some(Network::Mainnet)), + Some(CoreDataNetwork::Mainnet), + "{rel}" + ); + } +} + +#[test] +fn nonstandard_name_without_node_network_or_marker_is_not_mainnet_by_default() { + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, "btc-data"); + assert_eq!(open(&store, &core, None), None); +} + +#[test] +fn nonstandard_name_follows_node_network() { + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, "chain-data"); + assert_eq!( + open(&store, &core, Some(Network::Testnet4)), + Some(CoreDataNetwork::Testnet4) + ); +} + +#[test] +fn mainnet_marker_on_standard_signet_folder_is_refused() { + // S3: the marker used to win unchecked and open a mainnet reader here. + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, ".bitcoin/signet"); + write_marker(&store, &core, "mainnet"); + assert_eq!(open(&store, &core, None), None); + assert_eq!(open(&store, &core, Some(Network::Mainnet)), None); +} + +#[test] +fn signet_marker_on_custom_signet_folder_is_refused() { + // S3: a signet marker on signet_ used to open a reader with the + // standard signet magic. + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, "signet_0f9188f13cb7b2c71f2a335e3a4fc328"); + write_marker(&store, &core, "signet"); + assert_eq!(open(&store, &core, None), None); + assert_eq!(open(&store, &core, Some(Network::Signet)), None); +} + +#[test] +fn marker_and_folder_disagreement_is_refused() { + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, ".bitcoin/testnet3"); + write_marker(&store, &core, "testnet4"); + assert_eq!(open(&store, &core, None), None); +} + +#[test] +fn marker_and_node_disagreement_is_refused() { + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, "btc-data"); + write_marker(&store, &core, "mainnet"); + assert_eq!(open(&store, &core, Some(Network::Regtest)), None); +} + +#[test] +fn folder_and_node_disagreement_is_refused() { + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, ".bitcoin/testnet3"); + assert_eq!(open(&store, &core, Some(Network::Mainnet)), None); +} + +#[test] +fn agreeing_sources_open_the_reader() { + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, ".bitcoin/signet"); + write_marker(&store, &core, "signet"); + assert_eq!( + open(&store, &core, Some(Network::Signet)), + Some(CoreDataNetwork::Signet) + ); + + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, "btc-data"); + write_marker(&store, &core, "mainnet"); + assert_eq!(open(&store, &core, None), Some(CoreDataNetwork::Mainnet)); + assert_eq!( + open(&store, &core, Some(Network::Mainnet)), + Some(CoreDataNetwork::Mainnet) + ); +} + +#[test] +fn open_for_node_passes_the_node_network_to_core_reuse() { + // End to end through the public constructor: a mainnet Core datadir with a + // non-standard name opened with the node network must keep reuse; the same + // datadir with no network supplied must not silently become mainnet. + use crate::storage::database::DatabaseBackend; + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, "btc-data"); + let config = reuse_on(); + let mainnet = Storage::with_backend_pruning_and_indexing( + &store, + DatabaseBackend::RocksDB, + None, + None, + Some(&config), + Some(&core), + Some(Network::Mainnet), + ) + .unwrap(); + assert_eq!( + mainnet.blocks().bitcoin_core_reader_network(), + Some(CoreDataNetwork::Mainnet) + ); + drop(mainnet); + + let temp = TempDir::new().unwrap(); + let (core, store) = setup(&temp, "btc-data"); + let unknown = Storage::with_backend_pruning_and_indexing( + &store, + DatabaseBackend::RocksDB, + None, + None, + Some(&config), + Some(&core), + None, + ) + .unwrap(); + assert_eq!(unknown.blocks().bitcoin_core_reader_network(), None); +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 74a7bae..4b1aca5 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -19,6 +19,8 @@ pub mod blockstore; pub mod chainstate; #[cfg(feature = "utxo-commitments")] pub mod commitment_store; +#[cfg(all(test, feature = "rocksdb"))] +mod core_reuse_tests; pub mod database; pub mod disk_utxo; pub mod hashing; @@ -467,14 +469,25 @@ impl Storage { } } + /// Attempt to open a Bitcoin Core block reader for reuse. + /// + /// The network is resolved by [`BitcoinCoreDetection::resolve_reuse_network`]: + /// migration marker, then Core folder name, then `node_network`, all known + /// sources required to agree. + /// + /// Returns `None` if: + /// - Reuse is not enabled (via config or marker) + /// - The Core blocks directory doesn't exist + /// - The network cannot be resolved: custom signet (`signet_`) folder, + /// sources that disagree, or no source at all #[cfg(feature = "rocksdb")] - fn open_core_block_reader_for_store( + pub(crate) fn open_core_block_reader_for_store( blvm_store: &Path, core_datadir: Option<&Path>, storage_config: Option<&crate::config::StorageConfig>, + node_network: Option, ) -> Option> { use bitcoin_core_migrate::read_migration_marker; - use std::str::FromStr; let marker = read_migration_marker(blvm_store).ok().flatten(); let reuse = storage_config @@ -501,11 +514,20 @@ impl Storage { return None; } - let network = marker - .as_ref() - .and_then(|m| CoreDataNetwork::from_str(&m.network).ok()) - .or_else(|| BitcoinCoreDetection::detect_network(&core_dir)) - .unwrap_or(CoreDataNetwork::Mainnet); + let network = match BitcoinCoreDetection::resolve_reuse_network( + &core_dir, + marker.as_ref().map(|m| m.network.as_str()), + node_network.map(Self::protocol_to_core_network), + ) { + Ok(n) => n, + Err(e) => { + warn!( + "[CORE_IMPORT] Skipping Core block reuse for {:?}: {e}", + core_dir + ); + return None; + } + }; match bitcoin_core_blocks::BitcoinCoreBlockReader::new_with_cache( &blocks_dir, @@ -531,11 +553,18 @@ impl Storage { blvm_store: &Path, core_datadir: Option<&Path>, storage_config: Option<&crate::config::StorageConfig>, + node_network: Option, ) -> Result> { + #[cfg(not(feature = "rocksdb"))] + let _ = node_network; #[cfg(feature = "rocksdb")] { - let reader = - Self::open_core_block_reader_for_store(blvm_store, core_datadir, storage_config); + let reader = Self::open_core_block_reader_for_store( + blvm_store, + core_datadir, + storage_config, + node_network, + ); if let Some(reader) = reader { return Ok(Arc::new( blockstore::BlockStore::new_with_bitcoin_core_reader(db, Some(reader))?, @@ -589,7 +618,9 @@ impl Storage { compression_config, storage_config, core_datadir, - Some(network), + // The caller's network, not the Mainnet default: Core block reuse must + // not treat "unknown" as mainnet. + consensus_network, ) } #[cfg(not(feature = "compression"))] @@ -597,8 +628,13 @@ impl Storage { // When compression feature is disabled, use the internal implementation let store_path = data_dir.as_ref(); let db = Arc::from(create_database(store_path, backend, storage_config)?); - let blockstore = - Self::open_blockstore(Arc::clone(&db), store_path, core_datadir, storage_config)?; + let blockstore = Self::open_blockstore( + Arc::clone(&db), + store_path, + core_datadir, + storage_config, + consensus_network, + )?; let utxostore = Arc::new(utxostore::UtxoStore::new(Arc::clone(&db))?); let chainstate = chainstate::ChainState::new(Arc::clone(&db))?; @@ -731,6 +767,7 @@ impl Storage { store_path, core_datadir, storage_config, + consensus_network, ); Arc::new( blockstore::BlockStore::new_with_compression_and_bitcoin_core_reader( @@ -756,8 +793,13 @@ impl Storage { }; #[cfg(not(feature = "compression"))] - let blockstore = - Self::open_blockstore(Arc::clone(&db), store_path, core_datadir, storage_config)?; + let blockstore = Self::open_blockstore( + Arc::clone(&db), + store_path, + core_datadir, + storage_config, + consensus_network, + )?; let utxostore = if let Some(compression) = &compression_config { Arc::new(utxostore::UtxoStore::new_with_compression( Arc::clone(&db), diff --git a/tests/bitcoin_core_tests.rs b/tests/bitcoin_core_tests.rs index 768325e..089aa23 100644 --- a/tests/bitcoin_core_tests.rs +++ b/tests/bitcoin_core_tests.rs @@ -278,6 +278,174 @@ mod bitcoin_core_tests { create_dir_all(temp.path().join("blocks")).unwrap(); assert!(!BitcoinCoreDetection::is_core_layout_at(temp.path())); } + + fn create_core_layout(dir: &std::path::Path) { + let chainstate = dir.join("chainstate"); + create_dir_all(&chainstate).unwrap(); + std::fs::write(chainstate.join("CURRENT"), "MANIFEST-000001\n").unwrap(); + std::fs::write(chainstate.join("MANIFEST-000001"), b"").unwrap(); + create_dir_all(dir.join("blocks")).unwrap(); + } + + #[test] + fn test_detect_network_custom_signet_returns_none() { + let temp = TempDir::new().unwrap(); + let custom_signet = temp.path().join("signet_abc123def456"); + create_dir_all(&custom_signet).unwrap(); + + let detected = BitcoinCoreDetection::detect_network(&custom_signet); + assert_eq!( + detected, None, + "Custom signet should not silently fall back to mainnet" + ); + } + + #[test] + fn test_detect_network_strict_custom_signet_errors() { + let temp = TempDir::new().unwrap(); + let custom_signet = temp.path().join("signet_0f9188f13cb7b2c71f2a335e3a4fc328"); + create_dir_all(&custom_signet).unwrap(); + + let result = BitcoinCoreDetection::detect_network_strict(&custom_signet); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Custom signet directory detected"), + "Error should mention custom signet: {err_msg}" + ); + } + + #[test] + fn test_is_bitcoin_core_dir_respects_network_argument() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + let testnet4_dir = base.join("testnet4"); + create_core_layout(&testnet4_dir); + + assert!( + BitcoinCoreDetection::is_bitcoin_core_dir(base, CoreDataNetwork::Testnet4), + "Should detect testnet4 layout in testnet4/ subdir" + ); + assert!( + !BitcoinCoreDetection::is_bitcoin_core_dir(base, CoreDataNetwork::Mainnet), + "Should NOT detect mainnet when only testnet4/ exists" + ); + assert!( + !BitcoinCoreDetection::is_bitcoin_core_dir(base, CoreDataNetwork::Testnet), + "Should NOT detect testnet3 when only testnet4/ exists" + ); + } + + #[test] + fn test_is_bitcoin_core_dir_mainnet_at_root() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + create_core_layout(base); + + assert!( + BitcoinCoreDetection::is_bitcoin_core_dir(base, CoreDataNetwork::Mainnet), + "Should detect mainnet layout at root" + ); + assert!( + !BitcoinCoreDetection::is_bitcoin_core_dir(base, CoreDataNetwork::Testnet4), + "Should NOT detect testnet4 when data is at root" + ); + } + + #[test] + fn test_testnet4_datadir_integration() { + let temp = TempDir::new().unwrap(); + let base = temp.path().join(".bitcoin"); + create_dir_all(&base).unwrap(); + + let testnet4_dir = base.join("testnet4"); + create_core_layout(&testnet4_dir); + + assert_eq!( + BitcoinCoreDetection::detect_network(&testnet4_dir), + Some(CoreDataNetwork::Testnet4), + "detect_network should identify testnet4 subdirectory" + ); + + assert!( + BitcoinCoreDetection::is_bitcoin_core_dir(&base, CoreDataNetwork::Testnet4), + "is_bitcoin_core_dir should find testnet4 data under base" + ); + + assert!( + BitcoinCoreDetection::is_core_layout_at(&testnet4_dir), + "is_core_layout_at should validate testnet4 directory structure" + ); + + assert!( + BitcoinCoreDetection::verify_database(&testnet4_dir).is_ok(), + "verify_database should pass for valid testnet4 layout" + ); + } + + #[test] + fn test_all_networks_directory_layout() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); + + create_core_layout(base); + create_core_layout(&base.join("testnet3")); + create_core_layout(&base.join("testnet4")); + create_core_layout(&base.join("signet")); + create_core_layout(&base.join("regtest")); + + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Mainnet + )); + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Testnet + )); + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Testnet4 + )); + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Signet + )); + assert!(BitcoinCoreDetection::is_bitcoin_core_dir( + base, + CoreDataNetwork::Regtest + )); + } + + // The reader-level custom signet test lives in src/storage/core_reuse_tests.rs: + // `Storage::open_core_block_reader_for_store` is pub(crate). + + #[test] + fn test_standard_signet_core_dir_detection_works() { + // Verify that standard signet directories are detected correctly + // (as opposed to custom signet_ which returns None) + let temp = TempDir::new().unwrap(); + + // Create a standard signet directory + let signet_dir = temp.path().join("signet"); + create_dir_all(&signet_dir).unwrap(); + + let detected = BitcoinCoreDetection::detect_network(&signet_dir); + assert_eq!( + detected, + Some(CoreDataNetwork::Signet), + "Standard signet directory should be detected as Signet" + ); + + // Contrast with custom signet + let custom_signet = temp.path().join("signet_abc123"); + create_dir_all(&custom_signet).unwrap(); + + let detected_custom = BitcoinCoreDetection::detect_network(&custom_signet); + assert_eq!( + detected_custom, None, + "Custom signet_ directory should return None, not Mainnet" + ); + } } #[cfg(not(feature = "rocksdb"))]