diff --git a/dash-spv/src/client/config.rs b/dash-spv/src/client/config.rs index 23849f066..ef84a75ac 100644 --- a/dash-spv/src/client/config.rs +++ b/dash-spv/src/client/config.rs @@ -4,10 +4,10 @@ use clap::ValueEnum; use std::net::SocketAddr; use std::path::PathBuf; -use dashcore::sml::llmq_type::{set_llmq_devnet_params, LlmqDevnetParams}; use dashcore::Network; // Serialization removed due to complex Address types +use crate::client::devnet::DevnetConfig; use crate::types::ValidationMode; /// Strategy for handling mempool (unconfirmed) transactions. @@ -72,9 +72,8 @@ pub struct ClientConfig { /// The client will use the nearest checkpoint at or before this height. pub start_from_height: Option, - /// Override for `LLMQ_DEVNET` quorum size and threshold, applied at startup. - /// Mirrors Dash Core's `-llmqdevnetparams=:`. Only meaningful on devnet. - pub llmq_devnet_params: Option, + /// Devnet-only configuration. Must be `Some` iff `network == Network::Devnet`. + pub devnet: Option, } impl Default for ClientConfig { @@ -95,7 +94,7 @@ impl Default for ClientConfig { max_mempool_transactions: 1000, fetch_mempool_transactions: true, start_from_height: None, - llmq_devnet_params: None, + devnet: None, } } } @@ -187,10 +186,10 @@ impl ClientConfig { self } - /// Override `LLMQ_DEVNET` quorum size and threshold for a devnet. - /// Mirrors Dash Core's `-llmqdevnetparams=:`. - pub fn with_llmq_devnet_params(mut self, params: LlmqDevnetParams) -> Self { - self.llmq_devnet_params = Some(params); + /// Attach a [`DevnetConfig`]. The network must be `Network::Devnet`. + /// [`validate`](Self::validate) enforces the biconditional. + pub fn with_devnet(mut self, devnet: DevnetConfig) -> Self { + self.devnet = Some(devnet); self } @@ -209,8 +208,18 @@ impl ClientConfig { ); } - if self.llmq_devnet_params.is_some() && self.network != Network::Devnet { - return Err("llmq_devnet_params is only valid on devnet".to_string()); + match (self.network == Network::Devnet, &self.devnet) { + (true, Some(devnet)) => devnet.validate()?, + (true, None) => { + return Err("network is Devnet but no DevnetConfig was provided".to_string()); + } + (false, Some(_)) => { + return Err(format!( + "DevnetConfig is only valid on Devnet, but network is {:?}", + self.network + )); + } + (false, None) => {} } std::fs::create_dir_all(&self.storage_path).map_err(|e| { @@ -226,8 +235,8 @@ impl ClientConfig { /// Apply process-wide settings derived from this config. Idempotent for the /// same values, returns an error if a conflicting setting was already applied. pub(crate) fn apply_global_overrides(&self) -> Result<(), String> { - if let Some(params) = self.llmq_devnet_params { - set_llmq_devnet_params(params).map_err(|e| e.to_string())?; + if let Some(devnet) = &self.devnet { + devnet.apply_global_overrides()?; } Ok(()) } diff --git a/dash-spv/src/client/config_test.rs b/dash-spv/src/client/config_test.rs index edc043090..dd1a763d5 100644 --- a/dash-spv/src/client/config_test.rs +++ b/dash-spv/src/client/config_test.rs @@ -3,10 +3,16 @@ #[cfg(test)] mod tests { use crate::client::config::{ClientConfig, MempoolStrategy}; + use crate::client::devnet::DevnetConfig; use crate::types::ValidationMode; + use dashcore::sml::llmq_type::{ + devnet_chain_locks_type_override, devnet_isd_type_override, devnet_platform_type_override, + llmq_devnet_params, LLMQType, LlmqDevnetParams, + }; use dashcore::Network; use std::net::SocketAddr; use std::path::PathBuf; + use tempfile::TempDir; #[test] fn test_default_config() { @@ -24,6 +30,8 @@ mod tests { assert_eq!(config.mempool_strategy, MempoolStrategy::FetchAll); assert_eq!(config.max_mempool_transactions, 1000); assert!(config.fetch_mempool_transactions); + + assert!(config.devnet.is_none()); } #[test] @@ -31,14 +39,17 @@ mod tests { let mainnet = ClientConfig::mainnet(); assert_eq!(mainnet.network, Network::Mainnet); assert!(mainnet.peers.is_empty()); // Should use DNS discovery + assert!(mainnet.devnet.is_none()); let testnet = ClientConfig::testnet(); assert_eq!(testnet.network, Network::Testnet); assert!(testnet.peers.is_empty()); // Should use DNS discovery + assert!(testnet.devnet.is_none()); let regtest = ClientConfig::regtest(); assert_eq!(regtest.network, Network::Regtest); assert!(regtest.peers.is_empty()); + assert!(regtest.devnet.is_none()); } #[test] @@ -60,6 +71,83 @@ mod tests { assert_eq!(config.mempool_strategy, MempoolStrategy::BloomFilter); assert_eq!(config.max_mempool_transactions, 500); assert_eq!(config.start_from_height, Some(100000)); + assert!(config.devnet.is_none()); + } + + #[test] + fn test_with_devnet_round_trip() { + let devnet = DevnetConfig::new("alpha") + .with_llmq_params(LlmqDevnetParams { + size: 6, + threshold: 4, + }) + .with_chainlocks_type(LLMQType::Llmqtype50_60) + .with_instantsend_dip0024_type(LLMQType::LlmqtypeDevnetDIP0024) + .with_platform_type(LLMQType::LlmqtypeDevnetPlatform); + + let config = ClientConfig::new(Network::Devnet).with_devnet(devnet); + + let devnet = config.devnet.as_ref().expect("devnet must be set"); + assert_eq!(devnet.name, "alpha"); + assert_eq!( + devnet.llmq_params, + Some(LlmqDevnetParams { + size: 6, + threshold: 4 + }) + ); + assert_eq!(devnet.llmq_chainlocks_type, Some(LLMQType::Llmqtype50_60)); + assert_eq!(devnet.llmq_instantsend_dip0024_type, Some(LLMQType::LlmqtypeDevnetDIP0024)); + assert_eq!(devnet.llmq_platform_type, Some(LLMQType::LlmqtypeDevnetPlatform)); + } + + #[test] + fn test_user_agent_format_matches_dash_core() { + let devnet = DevnetConfig::new("alpha"); + assert_eq!(devnet.user_agent("0.43.0"), "/rust-dash-spv:0.43.0(devnet.devnet-alpha)/"); + } + + #[test] + fn test_validate_devnet_matrix() { + let tmp = TempDir::new().unwrap(); + let networks = [Network::Mainnet, Network::Testnet, Network::Regtest, Network::Devnet]; + for network in networks { + let want_devnet = network == Network::Devnet; + for has_devnet in [false, true] { + let mut config = + ClientConfig::new(network).with_storage_path(tmp.path().join("storage")); + if has_devnet { + config = config.with_devnet(DevnetConfig::new("alpha")); + } + let result = config.validate(); + if has_devnet == want_devnet { + assert!( + result.is_ok(), + "network={:?} has_devnet={} should be OK, got {:?}", + network, + has_devnet, + result + ); + } else { + assert!( + result.is_err(), + "network={:?} has_devnet={} must error", + network, + has_devnet + ); + } + } + } + } + + #[test] + fn test_validate_rejects_empty_devnet_name() { + let tmp = TempDir::new().unwrap(); + let config = ClientConfig::new(Network::Devnet) + .with_storage_path(tmp.path().join("storage")) + .with_devnet(DevnetConfig::new("")); + let err = config.validate().expect_err("empty name must be rejected"); + assert!(err.contains("must not be empty"), "got: {}", err); } #[test] @@ -109,5 +197,39 @@ mod tests { assert!(result.unwrap_err().contains("max_mempool_transactions must be > 0")); } - // Removed selective strategy validation test; Selective variant no longer exists + #[test] + fn test_apply_global_overrides_no_devnet_is_noop() { + let tmp = TempDir::new().unwrap(); + let config = ClientConfig::new(Network::Mainnet).with_storage_path(tmp.path()); + assert!(config.apply_global_overrides().is_ok()); + } + + // Each `dashcore` `OnceLock` accepts only one value per process; all four + // slots are exercised here in one shot. + #[test] + fn test_apply_global_overrides_forwards_all_slots() { + let tmp = TempDir::new().unwrap(); + let devnet = DevnetConfig::new("alpha") + .with_llmq_params(LlmqDevnetParams { + size: 11, + threshold: 7, + }) + .with_chainlocks_type(LLMQType::Llmqtype100_67) + .with_instantsend_dip0024_type(LLMQType::Llmqtype60_75) + .with_platform_type(LLMQType::LlmqtypeDevnet); + let config = + ClientConfig::new(Network::Devnet).with_storage_path(tmp.path()).with_devnet(devnet); + + config.apply_global_overrides().expect("forwarding all four overrides must succeed"); + + let params = llmq_devnet_params(); + assert_eq!(params.size, 11); + assert_eq!(params.threshold, 7); + assert_eq!(devnet_chain_locks_type_override(), Some(LLMQType::Llmqtype100_67)); + assert_eq!(devnet_isd_type_override(), Some(LLMQType::Llmqtype60_75)); + assert_eq!(devnet_platform_type_override(), Some(LLMQType::LlmqtypeDevnet)); + + // Re-applying the same config must be idempotent. + config.apply_global_overrides().expect("idempotent re-apply"); + } } diff --git a/dash-spv/src/client/devnet.rs b/dash-spv/src/client/devnet.rs new file mode 100644 index 000000000..433f0769a --- /dev/null +++ b/dash-spv/src/client/devnet.rs @@ -0,0 +1,106 @@ +//! Devnet-only configuration knobs that mirror Dash Core's `-devnet=`, +//! `-llmqdevnetparams`, and the three `-llmq{chainlocks,instantsenddip0024,platform}` +//! routing flags. Grouped into a single struct so the cross-field invariant +//! "presence iff `Network::Devnet`" is expressible at the `ClientConfig` level. + +use dashcore::sml::llmq_type::{ + set_devnet_chain_locks_type, set_devnet_isd_type, set_devnet_platform_type, + set_llmq_devnet_params, LLMQType, LlmqDevnetParams, +}; + +/// Configuration values that only apply on `Network::Devnet`. +/// +/// The `name` field is required because Dash Core embeds the devnet name into +/// both the genesis-block discovery and the peer-handshake user agent. Without +/// a name the SPV client cannot complete a devnet handshake against `dashd`. +/// Dash Core itself technically accepts `-devnet` with no name (defaulting the +/// network name to `"devnet"`), but every real devnet is launched with one. +#[derive(Debug, Clone)] +pub struct DevnetConfig { + /// Devnet name. Embedded in the user agent suffix + /// (`devnet.devnet-`) so peers gating on the name accept us. + pub name: String, + /// Override for `LLMQ_DEVNET` quorum size and threshold. + /// Mirrors Dash Core's `-llmqdevnetparams=:`. + pub llmq_params: Option, + /// Reroute ChainLocks onto a different devnet LLMQ type. + /// Mirrors Dash Core's `-llmqchainlocks=`. + pub llmq_chainlocks_type: Option, + /// Reroute InstantSend DIP24 locks onto a different devnet LLMQ type. + /// Mirrors Dash Core's `-llmqinstantsenddip0024=`. + pub llmq_instantsend_dip0024_type: Option, + /// Reroute Platform quorums onto a different devnet LLMQ type. + /// Mirrors Dash Core's `-llmqplatform=`. + pub llmq_platform_type: Option, +} + +impl DevnetConfig { + /// Create a new devnet config with no overrides. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + llmq_params: None, + llmq_chainlocks_type: None, + llmq_instantsend_dip0024_type: None, + llmq_platform_type: None, + } + } + + /// Set `LLMQ_DEVNET` size and threshold override. + pub fn with_llmq_params(mut self, params: LlmqDevnetParams) -> Self { + self.llmq_params = Some(params); + self + } + + /// Set the ChainLocks LLMQ routing override. + pub fn with_chainlocks_type(mut self, llmq_type: LLMQType) -> Self { + self.llmq_chainlocks_type = Some(llmq_type); + self + } + + /// Set the InstantSend DIP24 LLMQ routing override. + pub fn with_instantsend_dip0024_type(mut self, llmq_type: LLMQType) -> Self { + self.llmq_instantsend_dip0024_type = Some(llmq_type); + self + } + + /// Set the Platform LLMQ routing override. + pub fn with_platform_type(mut self, llmq_type: LLMQType) -> Self { + self.llmq_platform_type = Some(llmq_type); + self + } + + /// Render the user agent suffix that signals devnet identity to peers, + /// matching the format `dashd` itself uses: `/(devnet.devnet-)/`. + pub fn user_agent(&self, crate_version: &str) -> String { + format!("/rust-dash-spv:{}(devnet.devnet-{})/", crate_version, self.name) + } + + pub(crate) fn validate(&self) -> Result<(), String> { + if self.name.is_empty() { + return Err("devnet name must not be empty".to_string()); + } + if self.name.contains('/') { + return Err("devnet name must not contain '/'".to_string()); + } + Ok(()) + } + + /// Apply the four `dashcore` process-global overrides. Idempotent for + /// identical values, errors on conflicting re-set or invalid type. + pub(crate) fn apply_global_overrides(&self) -> Result<(), String> { + if let Some(params) = self.llmq_params { + set_llmq_devnet_params(params).map_err(|e| e.to_string())?; + } + if let Some(t) = self.llmq_chainlocks_type { + set_devnet_chain_locks_type(t)?; + } + if let Some(t) = self.llmq_instantsend_dip0024_type { + set_devnet_isd_type(t)?; + } + if let Some(t) = self.llmq_platform_type { + set_devnet_platform_type(t)?; + } + Ok(()) + } +} diff --git a/dash-spv/src/client/mod.rs b/dash-spv/src/client/mod.rs index a907281e6..2510ee0f6 100644 --- a/dash-spv/src/client/mod.rs +++ b/dash-spv/src/client/mod.rs @@ -15,6 +15,7 @@ //! - `sync_coordinator.rs` - Sync orchestration and network monitoring pub mod config; +pub mod devnet; pub mod event_handler; mod core; @@ -26,6 +27,7 @@ mod transactions; // Re-export public types from extracted modules pub use config::ClientConfig; +pub use devnet::DevnetConfig; pub use event_handler::EventHandler; // Re-export the main client struct diff --git a/dash-spv/src/lib.rs b/dash-spv/src/lib.rs index a96066bab..ecf8f432a 100644 --- a/dash-spv/src/lib.rs +++ b/dash-spv/src/lib.rs @@ -74,7 +74,7 @@ pub mod validation; // Re-export main types for convenience pub use client::config::MempoolStrategy; -pub use client::{ClientConfig, DashSpvClient, EventHandler}; +pub use client::{ClientConfig, DashSpvClient, DevnetConfig, EventHandler}; pub use error::{ LoggingError, LoggingResult, NetworkError, SpvError, StorageError, SyncError, ValidationError, }; @@ -95,7 +95,7 @@ pub use dashcore::sml::masternode_list_engine::{ }; // Re-export LLMQ types -pub use dashcore::sml::llmq_type::LLMQType; +pub use dashcore::sml::llmq_type::{LLMQType, LlmqDevnetParams}; /// Current version of the dash-spv library. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/dash-spv/src/main.rs b/dash-spv/src/main.rs index 22e64f236..eca7d7a6a 100644 --- a/dash-spv/src/main.rs +++ b/dash-spv/src/main.rs @@ -5,8 +5,11 @@ use std::process; use std::sync::Arc; use clap::{Parser, ValueEnum}; -use dash_spv::{ClientConfig, DashSpvClient, LevelFilter, MempoolStrategy, Network}; -use dashcore::sml::llmq_type::LlmqDevnetParams; +use dash_spv::{ + ClientConfig, DashSpvClient, DevnetConfig, LevelFilter, LlmqDevnetParams, MempoolStrategy, + Network, ValidationMode, +}; +use dashcore::sml::llmq_type::devnet_llmq_type_from_name; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet_manager::WalletManager; @@ -143,6 +146,23 @@ struct Args { /// Override `LLMQ_DEVNET` size and threshold (matches Dash Core's `-llmqdevnetparams=:`). #[arg(long, value_name = "SIZE:THRESHOLD")] llmq_devnet_params: Option, + + /// Reroute ChainLocks onto the given devnet quorum (matches Dash Core's + /// `-llmqchainlocks=`). Type must be devnet-registered and + /// non-rotating. + #[arg(long, value_name = "QUORUM_NAME")] + llmq_chainlocks: Option, + + /// Reroute InstantSend DIP24 onto the given devnet quorum (matches Dash Core's + /// `-llmqinstantsenddip0024=`). Type must be devnet-registered + /// and rotating. + #[arg(long, value_name = "QUORUM_NAME")] + llmq_instantsend_dip0024: Option, + + /// Reroute Platform quorums onto the given devnet quorum (matches Dash Core's + /// `-llmqplatform=`). Type must be devnet-registered. + #[arg(long, value_name = "QUORUM_NAME")] + llmq_platform: Option, } #[tokio::main] @@ -218,64 +238,69 @@ async fn run() -> Result<(), Box> { tracing::info!("Data directory: {}", data_dir.display()); tracing::info!("Validation mode: {:?}", validation_mode); - // Create configuration - let mut config = ClientConfig::new(network) - .with_storage_path(data_dir.clone()) - .with_validation_mode(validation_mode); - - if network == Network::Devnet { - let devnet_name = - args.devnet_name.as_deref().ok_or("--devnet-name is required when --network=devnet")?; - let user_agent = - format!("/rust-dash-spv:{}(devnet.devnet-{})/", dash_spv::VERSION, devnet_name); + let mut config = build_client_config(&args, data_dir.clone())?; + if let Err(e) = config.validate() { + tracing::error!("Configuration error: {}", e); + process::exit(1); + } + if let Some(devnet) = &config.devnet { + let user_agent = devnet.user_agent(dash_spv::VERSION); tracing::info!("Devnet user agent: {}", user_agent); config = config.with_user_agent(user_agent); + } - if let Some(raw) = args.llmq_devnet_params.as_deref() { - let (size_str, threshold_str) = raw.split_once(':').ok_or_else(|| { - format!("--llmq-devnet-params expects SIZE:THRESHOLD, got '{}'", raw) - })?; - let size: u32 = size_str - .parse() - .map_err(|e| format!("invalid LLMQ_DEVNET size '{}': {}", size_str, e))?; - let threshold: u32 = threshold_str - .parse() - .map_err(|e| format!("invalid LLMQ_DEVNET threshold '{}': {}", threshold_str, e))?; - let params = LlmqDevnetParams { - size, - threshold, - }; - config = config.with_llmq_devnet_params(params); - tracing::info!( - "LLMQ_DEVNET params overridden: size={} threshold={}", - params.size, - params.threshold - ); - } - } else { - if args.devnet_name.is_some() { - return Err("--devnet-name is only valid with --network=devnet".into()); + // Create the wallet manager + let mut wallet_manager = WalletManager::::new(config.network); + wallet_manager.create_wallet_from_mnemonic( + mnemonic_phrase.as_str(), + 0, + key_wallet::wallet::initialization::WalletAccountCreationOptions::default(), + )?; + let wallet = Arc::new(tokio::sync::RwLock::new(wallet_manager)); + + // Create network manager + let network_manager = match dash_spv::network::manager::PeerNetworkManager::new(&config).await { + Ok(nm) => nm, + Err(e) => { + eprintln!("Failed to create network manager: {}", e); + process::exit(1); } - if args.llmq_devnet_params.is_some() { - return Err("--llmq-devnet-params is only valid with --network=devnet".into()); + }; + + let storage_manager = match dash_spv::storage::DiskStorageManager::new(&config).await { + Ok(sm) => sm, + Err(e) => { + eprintln!("Failed to create disk storage manager: {}", e); + process::exit(1); } + }; + run_client(config, network_manager, storage_manager, wallet).await?; + + Ok(()) +} + +fn build_client_config(args: &Args, data_dir: PathBuf) -> Result { + let network: Network = args.network.into(); + let validation_mode: ValidationMode = args.validation_mode.into(); + + let mut config = ClientConfig::new(network) + .with_storage_path(data_dir) + .with_validation_mode(validation_mode); + + let devnet = build_devnet_config(args, network)?; + if let Some(devnet) = devnet { + config = config.with_devnet(devnet); } - // Add custom peers if specified if !args.peer.is_empty() { config.peers.clear(); for peer in &args.peer { - match peer.parse() { - Ok(addr) => config.add_peer(addr), - Err(e) => { - tracing::error!("Invalid peer address '{}': {}", peer, e); - process::exit(1); - } - }; + let addr = + peer.parse().map_err(|e| format!("Invalid peer address '{}': {}", peer, e))?; + config.add_peer(addr); } } - // Configure features if args.no_filters { config = config.without_filters(); } @@ -288,55 +313,71 @@ async fn run() -> Result<(), Box> { config = config.with_mempool_tracking(args.mempool_strategy); } - // Set start height if specified if let Some(ref start_height_str) = args.start_height { if start_height_str == "now" { - // Use a very high number to get the latest checkpoint config.start_from_height = Some(u32::MAX); - tracing::info!("Will start syncing from the latest available checkpoint"); } else { let start_height = start_height_str .parse::() .map_err(|e| format!("Invalid start height '{}': {}", start_height_str, e))?; config.start_from_height = Some(start_height); - tracing::info!("Will start syncing from height: {}", start_height); } } - // Validate configuration - if let Err(e) = config.validate() { - tracing::error!("Configuration error: {}", e); - process::exit(1); - } - - // Create the wallet manager - let mut wallet_manager = WalletManager::::new(config.network); - wallet_manager.create_wallet_from_mnemonic( - mnemonic_phrase.as_str(), - 0, - key_wallet::wallet::initialization::WalletAccountCreationOptions::default(), - )?; - let wallet = Arc::new(tokio::sync::RwLock::new(wallet_manager)); + Ok(config) +} - // Create network manager - let network_manager = match dash_spv::network::manager::PeerNetworkManager::new(&config).await { - Ok(nm) => nm, - Err(e) => { - eprintln!("Failed to create network manager: {}", e); - process::exit(1); +fn build_devnet_config(args: &Args, network: Network) -> Result, String> { + if network != Network::Devnet { + if args.devnet_name.is_some() { + return Err("--devnet-name is only valid with --network=devnet".into()); } - }; - - let storage_manager = match dash_spv::storage::DiskStorageManager::new(&config).await { - Ok(sm) => sm, - Err(e) => { - eprintln!("Failed to create disk storage manager: {}", e); - process::exit(1); + if args.llmq_devnet_params.is_some() { + return Err("--llmq-devnet-params is only valid with --network=devnet".into()); } - }; - run_client(config, network_manager, storage_manager, wallet).await?; + if args.llmq_chainlocks.is_some() { + return Err("--llmq-chainlocks is only valid with --network=devnet".into()); + } + if args.llmq_instantsend_dip0024.is_some() { + return Err("--llmq-instantsend-dip0024 is only valid with --network=devnet".into()); + } + if args.llmq_platform.is_some() { + return Err("--llmq-platform is only valid with --network=devnet".into()); + } + return Ok(None); + } - Ok(()) + let name = args.devnet_name.clone().ok_or("--devnet-name is required when --network=devnet")?; + let mut devnet = DevnetConfig::new(name); + + if let Some(raw) = args.llmq_devnet_params.as_deref() { + devnet = devnet.with_llmq_params(parse_llmq_devnet_params(raw)?); + } + if let Some(name) = args.llmq_chainlocks.as_deref() { + devnet = devnet.with_chainlocks_type(devnet_llmq_type_from_name(name)?); + } + if let Some(name) = args.llmq_instantsend_dip0024.as_deref() { + devnet = devnet.with_instantsend_dip0024_type(devnet_llmq_type_from_name(name)?); + } + if let Some(name) = args.llmq_platform.as_deref() { + devnet = devnet.with_platform_type(devnet_llmq_type_from_name(name)?); + } + Ok(Some(devnet)) +} + +fn parse_llmq_devnet_params(raw: &str) -> Result { + let (size_str, threshold_str) = raw + .split_once(':') + .ok_or_else(|| format!("--llmq-devnet-params expects SIZE:THRESHOLD, got '{}'", raw))?; + let size: u32 = + size_str.parse().map_err(|e| format!("invalid LLMQ_DEVNET size '{}': {}", size_str, e))?; + let threshold: u32 = threshold_str + .parse() + .map_err(|e| format!("invalid LLMQ_DEVNET threshold '{}': {}", threshold_str, e))?; + Ok(LlmqDevnetParams { + size, + threshold, + }) } async fn run_client( @@ -377,3 +418,129 @@ async fn run_client( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use dash_spv::LLMQType; + use tempfile::TempDir; + + fn parse(argv: &[&str]) -> Result { + let mut full = vec!["dash-spv"]; + full.extend_from_slice(argv); + Args::try_parse_from(full) + } + + fn args(extra: &[&str]) -> Args { + let mut argv = vec!["--mnemonic-file", "/dev/null"]; + argv.extend_from_slice(extra); + parse(&argv).expect("parse") + } + + #[test] + fn devnet_requires_name() { + let args = args(&["--network", "devnet"]); + let err = build_devnet_config(&args, Network::Devnet).expect_err("must require name"); + assert!(err.contains("--devnet-name is required"), "got: {}", err); + } + + #[test] + fn devnet_flags_rejected_on_non_devnet_networks() { + for flag in &[ + "--devnet-name", + "--llmq-devnet-params", + "--llmq-chainlocks", + "--llmq-instantsend-dip0024", + "--llmq-platform", + ] { + let value = if *flag == "--llmq-devnet-params" { + "8:5" + } else if *flag == "--devnet-name" { + "alpha" + } else { + "llmq_devnet" + }; + for network in [Network::Mainnet, Network::Testnet, Network::Regtest] { + let args = args(&[flag, value]); + let err = build_devnet_config(&args, network) + .expect_err("non-devnet network must reject the flag"); + assert!(err.contains(flag), "expected error to name flag {}, got: {}", flag, err); + } + } + } + + #[test] + fn devnet_minimal_builds_config_with_no_overrides() { + let args = args(&["--network", "devnet", "--devnet-name", "alpha"]); + let devnet = + build_devnet_config(&args, Network::Devnet).expect("must succeed").expect("some"); + assert_eq!(devnet.name, "alpha"); + assert!(devnet.llmq_params.is_none()); + assert!(devnet.llmq_chainlocks_type.is_none()); + assert!(devnet.llmq_instantsend_dip0024_type.is_none()); + assert!(devnet.llmq_platform_type.is_none()); + } + + #[test] + fn devnet_full_compose() { + let args = args(&[ + "--network", + "devnet", + "--devnet-name", + "alpha", + "--llmq-devnet-params", + "8:5", + "--llmq-chainlocks", + "llmq_devnet", + "--llmq-instantsend-dip0024", + "llmq_devnet_dip0024", + "--llmq-platform", + "llmq_devnet_platform", + ]); + let devnet = + build_devnet_config(&args, Network::Devnet).expect("must succeed").expect("some"); + assert_eq!(devnet.name, "alpha"); + assert_eq!( + devnet.llmq_params, + Some(LlmqDevnetParams { + size: 8, + threshold: 5 + }) + ); + assert_eq!(devnet.llmq_chainlocks_type, Some(LLMQType::LlmqtypeDevnet)); + assert_eq!(devnet.llmq_instantsend_dip0024_type, Some(LLMQType::LlmqtypeDevnetDIP0024)); + assert_eq!(devnet.llmq_platform_type, Some(LLMQType::LlmqtypeDevnetPlatform)); + } + + #[test] + fn llmq_devnet_params_parse_errors() { + assert!(parse_llmq_devnet_params("8").is_err(), "missing colon"); + assert!(parse_llmq_devnet_params("abc:5").is_err(), "non-numeric size"); + assert!(parse_llmq_devnet_params("8:abc").is_err(), "non-numeric threshold"); + assert!(parse_llmq_devnet_params(":").is_err(), "empty parts"); + } + + #[test] + fn unknown_quorum_name_is_rejected() { + let args = args(&[ + "--network", + "devnet", + "--devnet-name", + "alpha", + "--llmq-chainlocks", + "not_a_quorum", + ]); + let err = build_devnet_config(&args, Network::Devnet).expect_err("must reject"); + assert!(err.contains("Invalid LLMQ type"), "got: {}", err); + } + + #[test] + fn build_client_config_returns_devnet_on_devnet() { + let args = args(&["--network", "devnet", "--devnet-name", "alpha"]); + let tmp = TempDir::new().unwrap(); + let config = build_client_config(&args, tmp.path().to_path_buf()).expect("ok"); + let devnet = config.devnet.as_ref().expect("devnet must be set"); + assert_eq!(devnet.name, "alpha"); + assert_eq!(config.network, Network::Devnet); + } +} diff --git a/dash/src/sml/llmq_type/mod.rs b/dash/src/sml/llmq_type/mod.rs index 573804e14..dc0fb52b7 100644 --- a/dash/src/sml/llmq_type/mod.rs +++ b/dash/src/sml/llmq_type/mod.rs @@ -251,6 +251,141 @@ pub fn llmq_devnet_params() -> LLMQParams { params } +/// Runtime override for the LLMQ type used for ChainLocks on devnet, +/// matching Dash Core's `-llmqchainlocks=`. +static DEVNET_CHAIN_LOCKS_OVERRIDE: OnceLock = OnceLock::new(); +/// Runtime override for the LLMQ type used for InstantSend DIP24 on devnet, +/// matching Dash Core's `-llmqinstantsenddip0024=`. +static DEVNET_ISD_OVERRIDE: OnceLock = OnceLock::new(); +/// Runtime override for the LLMQ type used for Platform on devnet, +/// matching Dash Core's `-llmqplatform=`. +static DEVNET_PLATFORM_OVERRIDE: OnceLock = OnceLock::new(); + +/// LLMQ types registered on devnet by Dash Core (`chainparams.cpp` `CDevNetParams` +/// `AddLLMQ` calls). Routing overrides accept any of these as a target. +const DEVNET_REGISTERED_LLMQ_TYPES: [LLMQType; 8] = [ + LLMQType::Llmqtype50_60, + LLMQType::Llmqtype60_75, + LLMQType::Llmqtype400_60, + LLMQType::Llmqtype400_85, + LLMQType::Llmqtype100_67, + LLMQType::LlmqtypeDevnet, + LLMQType::LlmqtypeDevnetDIP0024, + LLMQType::LlmqtypeDevnetPlatform, +]; + +/// Parse a Dash-Core LLMQ name into an `LLMQType`. Mirrors the name set Dash Core +/// registers on devnet so `-llmqchainlocks` / `-llmqinstantsenddip0024` / +/// `-llmqplatform` accept the same strings as `dashd`. +/// +/// `llmq_dev_platform` is accepted as an alias for `llmq_devnet_platform` because +/// this crate's own `LLMQ_DEV_PLATFORM` constant uses the shorter spelling. +pub fn devnet_llmq_type_from_name(name: &str) -> Result { + match name { + "llmq_50_60" => Ok(LLMQType::Llmqtype50_60), + "llmq_60_75" => Ok(LLMQType::Llmqtype60_75), + "llmq_400_60" => Ok(LLMQType::Llmqtype400_60), + "llmq_400_85" => Ok(LLMQType::Llmqtype400_85), + "llmq_100_67" => Ok(LLMQType::Llmqtype100_67), + "llmq_devnet" => Ok(LLMQType::LlmqtypeDevnet), + "llmq_devnet_dip0024" => Ok(LLMQType::LlmqtypeDevnetDIP0024), + "llmq_devnet_platform" | "llmq_dev_platform" => Ok(LLMQType::LlmqtypeDevnetPlatform), + _ => Err(format!("Invalid LLMQ type: {}", name)), + } +} + +/// Constraint a devnet routing override must satisfy beyond being a devnet-registered type. +enum RotationConstraint { + MustNotRotate, + MustRotate, + Any, +} + +fn set_devnet_routing_override( + slot: &'static OnceLock, + flag_name: &str, + constraint: RotationConstraint, + llmq_type: LLMQType, +) -> Result<(), String> { + if !DEVNET_REGISTERED_LLMQ_TYPES.contains(&llmq_type) { + return Err(format!("Invalid LLMQ type specified for -{}.", flag_name)); + } + let rotates = llmq_type.is_rotating_quorum_type(); + match constraint { + RotationConstraint::MustNotRotate if rotates => { + return Err(format!("LLMQ type specified for -{} must NOT use rotation", flag_name)); + } + RotationConstraint::MustRotate if !rotates => { + return Err(format!("LLMQ type specified for -{} must use rotation", flag_name)); + } + _ => {} + } + match slot.get() { + Some(&existing) if existing == llmq_type => Ok(()), + Some(_) => Err(format!("-{} already set to a different value", flag_name)), + None => slot + .set(llmq_type) + .map_err(|_| format!("-{} already set to a different value", flag_name)), + } +} + +/// Override the LLMQ type used for ChainLocks (matches Dash Core's +/// `-llmqchainlocks=`). Type must be devnet-registered and +/// non-rotating, per Dash Core. Idempotent for identical values, errors on +/// conflicting re-set. +pub fn set_devnet_chain_locks_type(llmq_type: LLMQType) -> Result<(), String> { + set_devnet_routing_override( + &DEVNET_CHAIN_LOCKS_OVERRIDE, + "llmqchainlocks", + RotationConstraint::MustNotRotate, + llmq_type, + ) +} + +/// Override the LLMQ type used for InstantSend DIP24 (matches Dash Core's +/// `-llmqinstantsenddip0024=`). Type must be devnet-registered +/// and rotating, per Dash Core. Idempotent for identical values, errors on +/// conflicting re-set. +pub fn set_devnet_isd_type(llmq_type: LLMQType) -> Result<(), String> { + set_devnet_routing_override( + &DEVNET_ISD_OVERRIDE, + "llmqinstantsenddip0024", + RotationConstraint::MustRotate, + llmq_type, + ) +} + +/// Override the LLMQ type used for Platform (matches Dash Core's +/// `-llmqplatform=`). Type must be devnet-registered. Dash Core +/// imposes no rotation constraint for Platform. Idempotent for identical +/// values, errors on conflicting re-set. +pub fn set_devnet_platform_type(llmq_type: LLMQType) -> Result<(), String> { + set_devnet_routing_override( + &DEVNET_PLATFORM_OVERRIDE, + "llmqplatform", + RotationConstraint::Any, + llmq_type, + ) +} + +/// Returns the ChainLocks LLMQ override if one was set via +/// [`set_devnet_chain_locks_type`]. Only meaningful on devnet. +pub fn devnet_chain_locks_type_override() -> Option { + DEVNET_CHAIN_LOCKS_OVERRIDE.get().copied() +} + +/// Returns the InstantSend DIP24 LLMQ override if one was set via +/// [`set_devnet_isd_type`]. Only meaningful on devnet. +pub fn devnet_isd_type_override() -> Option { + DEVNET_ISD_OVERRIDE.get().copied() +} + +/// Returns the Platform LLMQ override if one was set via +/// [`set_devnet_platform_type`]. Only meaningful on devnet. +pub fn devnet_platform_type_override() -> Option { + DEVNET_PLATFORM_OVERRIDE.get().copied() +} + pub const LLMQ_50_60: LLMQParams = LLMQParams { quorum_type: LLMQType::Llmqtype50_60, name: "llmq_50_60", @@ -441,6 +576,7 @@ impl From for LLMQType { 104 => LLMQType::LlmqtypeTestInstantSend, 105 => LLMQType::LlmqtypeDevnetDIP0024, 106 => LLMQType::LlmqtypeTestnetPlatform, + 107 => LLMQType::LlmqtypeDevnetPlatform, _ => LLMQType::LlmqtypeUnknown, } } @@ -620,6 +756,7 @@ impl LLMQType { #[cfg(test)] mod tests { use super::*; + use crate::sml::llmq_type::network::NetworkLLMQExt; #[test] fn test_get_cycle_base_height() { @@ -706,11 +843,11 @@ mod tests { assert_eq!(params.signing_active_quorum_count, 24); } + // Each devnet `OnceLock` accepts only one value per process; the full contract + // must be exercised in a single test per lock. + #[test] fn test_llmq_devnet_override_lifecycle() { - // LLMQ_DEVNET_OVERRIDE is a process-global OnceLock, so the three contract - // checks (initial set, idempotent re-set, conflicting re-set) all run in - // this single test to avoid races between tests sharing the same lock. set_llmq_devnet_params(LlmqDevnetParams { size: 8, threshold: 5, @@ -741,4 +878,126 @@ mod tests { assert_eq!(params_after.size, 8); assert_eq!(params_after.threshold, 5); } + + #[test] + fn test_devnet_llmq_type_from_name() { + assert_eq!(devnet_llmq_type_from_name("llmq_50_60").unwrap(), LLMQType::Llmqtype50_60); + assert_eq!(devnet_llmq_type_from_name("llmq_60_75").unwrap(), LLMQType::Llmqtype60_75); + assert_eq!(devnet_llmq_type_from_name("llmq_400_60").unwrap(), LLMQType::Llmqtype400_60); + assert_eq!(devnet_llmq_type_from_name("llmq_400_85").unwrap(), LLMQType::Llmqtype400_85); + assert_eq!(devnet_llmq_type_from_name("llmq_100_67").unwrap(), LLMQType::Llmqtype100_67); + assert_eq!(devnet_llmq_type_from_name("llmq_devnet").unwrap(), LLMQType::LlmqtypeDevnet); + assert_eq!( + devnet_llmq_type_from_name("llmq_devnet_dip0024").unwrap(), + LLMQType::LlmqtypeDevnetDIP0024 + ); + assert_eq!( + devnet_llmq_type_from_name("llmq_devnet_platform").unwrap(), + LLMQType::LlmqtypeDevnetPlatform + ); + assert_eq!( + devnet_llmq_type_from_name("llmq_dev_platform").unwrap(), + LLMQType::LlmqtypeDevnetPlatform, + "shorter alias must resolve to the same type as `llmq_devnet_platform`" + ); + + assert!(devnet_llmq_type_from_name("").is_err()); + assert!(devnet_llmq_type_from_name("llmq_test").is_err()); + assert!(devnet_llmq_type_from_name("not_a_quorum").is_err()); + } + + #[test] + fn test_devnet_routing_setters_reject_invalid_types() { + // Regtest-only types are not registered on devnet in Dash Core, so the + // setters must refuse them before touching any `OnceLock` state. + for &llmq_type in &[ + LLMQType::LlmqtypeTest, + LLMQType::LlmqtypeTestDIP0024, + LLMQType::LlmqtypeTestInstantSend, + LLMQType::LlmqtypeTestnetPlatform, + ] { + assert!(set_devnet_chain_locks_type(llmq_type).is_err()); + assert!(set_devnet_isd_type(llmq_type).is_err()); + assert!(set_devnet_platform_type(llmq_type).is_err()); + } + + // ChainLocks must NOT use a rotating quorum (Dash Core + // `chainparams.cpp` `UpdateDevnetLLMQChainLocksFromArgs`). + let err = + set_devnet_chain_locks_type(LLMQType::LlmqtypeDevnetDIP0024).expect_err("must reject"); + assert!(err.contains("must NOT use rotation"), "got: {}", err); + + // InstantSend DIP24 MUST use a rotating quorum (Dash Core + // `UpdateDevnetLLMQInstantSendDIP0024FromArgs`). + let err = set_devnet_isd_type(LLMQType::LlmqtypeDevnet).expect_err("must reject"); + assert!(err.contains("must use rotation"), "got: {}", err); + } + + #[test] + fn test_devnet_chain_locks_override_lifecycle() { + assert!(devnet_chain_locks_type_override().is_none()); + assert_eq!( + Network::Devnet.chain_locks_type(), + LLMQType::LlmqtypeDevnet, + "default ChainLocks routing before override" + ); + + set_devnet_chain_locks_type(LLMQType::Llmqtype50_60) + .expect("non-rotating registered type should be accepted"); + + assert_eq!(devnet_chain_locks_type_override(), Some(LLMQType::Llmqtype50_60)); + assert_eq!(Network::Devnet.chain_locks_type(), LLMQType::Llmqtype50_60); + assert_eq!( + Network::Mainnet.chain_locks_type(), + LLMQType::Llmqtype400_60, + "other networks must be unaffected" + ); + + set_devnet_chain_locks_type(LLMQType::Llmqtype50_60) + .expect("idempotent re-set with same value"); + assert!( + set_devnet_chain_locks_type(LLMQType::Llmqtype400_60).is_err(), + "conflicting re-set must error" + ); + assert_eq!(Network::Devnet.chain_locks_type(), LLMQType::Llmqtype50_60); + } + + #[test] + fn test_devnet_isd_override_lifecycle() { + assert!(devnet_isd_type_override().is_none()); + assert_eq!(Network::Devnet.isd_llmq_type(), LLMQType::LlmqtypeDevnetDIP0024); + + set_devnet_isd_type(LLMQType::Llmqtype60_75).expect("rotating registered type accepted"); + + assert_eq!(devnet_isd_type_override(), Some(LLMQType::Llmqtype60_75)); + assert_eq!(Network::Devnet.isd_llmq_type(), LLMQType::Llmqtype60_75); + assert_eq!( + Network::Mainnet.isd_llmq_type(), + LLMQType::Llmqtype60_75, + "mainnet's default for ISD24 is independent of the devnet override" + ); + + set_devnet_isd_type(LLMQType::Llmqtype60_75).expect("idempotent"); + assert!(set_devnet_isd_type(LLMQType::LlmqtypeDevnetDIP0024).is_err()); + } + + #[test] + fn test_devnet_platform_override_lifecycle() { + assert!(devnet_platform_type_override().is_none()); + assert_eq!(Network::Devnet.platform_type(), LLMQType::LlmqtypeDevnetPlatform); + + set_devnet_platform_type(LLMQType::Llmqtype100_67) + .expect("non-rotating registered type accepted (no rotation constraint for Platform)"); + + assert_eq!(devnet_platform_type_override(), Some(LLMQType::Llmqtype100_67)); + assert_eq!(Network::Devnet.platform_type(), LLMQType::Llmqtype100_67); + assert_eq!( + Network::Regtest.platform_type(), + LLMQType::LlmqtypeTestnetPlatform, + "regtest platform routing must be unaffected" + ); + + set_devnet_platform_type(LLMQType::Llmqtype100_67).expect("idempotent"); + assert!(set_devnet_platform_type(LLMQType::LlmqtypeDevnet).is_err()); + } } diff --git a/dash/src/sml/llmq_type/network.rs b/dash/src/sml/llmq_type/network.rs index 65a6b7a00..75f94b77b 100644 --- a/dash/src/sml/llmq_type/network.rs +++ b/dash/src/sml/llmq_type/network.rs @@ -1,5 +1,8 @@ use crate::Network; -use crate::sml::llmq_type::{DKGWindow, LLMQType}; +use crate::sml::llmq_type::{ + DKGWindow, LLMQType, devnet_chain_locks_type_override, devnet_isd_type_override, + devnet_platform_type_override, +}; use std::collections::BTreeMap; /// Extension trait for Network to add LLMQ-specific methods @@ -27,7 +30,9 @@ impl NetworkLLMQExt for Network { match self { Network::Mainnet => LLMQType::Llmqtype60_75, Network::Testnet => LLMQType::Llmqtype60_75, - Network::Devnet => LLMQType::LlmqtypeDevnetDIP0024, + Network::Devnet => { + devnet_isd_type_override().unwrap_or(LLMQType::LlmqtypeDevnetDIP0024) + } Network::Regtest => LLMQType::LlmqtypeTestDIP0024, } } @@ -36,7 +41,9 @@ impl NetworkLLMQExt for Network { match self { Network::Mainnet => LLMQType::Llmqtype400_60, Network::Testnet => LLMQType::Llmqtype50_60, - Network::Devnet => LLMQType::LlmqtypeDevnet, + Network::Devnet => { + devnet_chain_locks_type_override().unwrap_or(LLMQType::LlmqtypeDevnet) + } Network::Regtest => LLMQType::LlmqtypeTest, } } @@ -45,7 +52,9 @@ impl NetworkLLMQExt for Network { match self { Network::Mainnet => LLMQType::Llmqtype100_67, Network::Testnet => LLMQType::Llmqtype25_67, - Network::Devnet => LLMQType::LlmqtypeDevnetPlatform, + Network::Devnet => { + devnet_platform_type_override().unwrap_or(LLMQType::LlmqtypeDevnetPlatform) + } Network::Regtest => LLMQType::LlmqtypeTestnetPlatform, } } @@ -67,6 +76,11 @@ impl NetworkLLMQExt for Network { LLMQType::Llmqtype25_67, // Platform consensus (smaller for testnet) ], Network::Devnet => vec![ + LLMQType::Llmqtype50_60, + LLMQType::Llmqtype60_75, + LLMQType::Llmqtype400_60, + LLMQType::Llmqtype400_85, + LLMQType::Llmqtype100_67, LLMQType::LlmqtypeDevnet, LLMQType::LlmqtypeDevnetDIP0024, LLMQType::LlmqtypeDevnetPlatform,