diff --git a/dash-spv/src/client/config.rs b/dash-spv/src/client/config.rs index 8d5dc7ef6..23849f066 100644 --- a/dash-spv/src/client/config.rs +++ b/dash-spv/src/client/config.rs @@ -4,6 +4,7 @@ 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 @@ -70,6 +71,10 @@ pub struct ClientConfig { /// Start syncing from a specific block height. /// 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, } impl Default for ClientConfig { @@ -90,6 +95,7 @@ impl Default for ClientConfig { max_mempool_transactions: 1000, fetch_mempool_transactions: true, start_from_height: None, + llmq_devnet_params: None, } } } @@ -181,6 +187,13 @@ 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); + self + } + /// Validate the configuration. pub fn validate(&self) -> Result<(), String> { // Note: Empty peers list is now valid - DNS discovery will be used automatically @@ -196,6 +209,10 @@ 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()); + } + std::fs::create_dir_all(&self.storage_path).map_err(|e| { format!( "A valid storage path must be provided to the ClientConfig {:?}: {e}", @@ -205,4 +222,13 @@ impl ClientConfig { Ok(()) } + + /// 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())?; + } + Ok(()) + } } diff --git a/dash-spv/src/client/lifecycle.rs b/dash-spv/src/client/lifecycle.rs index fc2b282d8..8c7d4f29f 100644 --- a/dash-spv/src/client/lifecycle.rs +++ b/dash-spv/src/client/lifecycle.rs @@ -40,6 +40,7 @@ impl DashSpvClient for Network { match arg { NetworkArg::Mainnet => Network::Mainnet, NetworkArg::Testnet => Network::Testnet, + NetworkArg::Devnet => Network::Devnet, NetworkArg::Regtest => Network::Regtest, } } @@ -132,6 +135,14 @@ struct Args { /// Path to file containing BIP39 mnemonic phrase #[arg(long, value_name = "PATH")] mnemonic_file: String, + + /// Devnet name (required when --network=devnet). Embedded in user agent so devnet peers accept the connection. + #[arg(long, value_name = "NAME")] + devnet_name: Option, + + /// Override `LLMQ_DEVNET` size and threshold (matches Dash Core's `-llmqdevnetparams=:`). + #[arg(long, value_name = "SIZE:THRESHOLD")] + llmq_devnet_params: Option, } #[tokio::main] @@ -212,6 +223,44 @@ async fn run() -> Result<(), Box> { .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); + 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()); + } + if args.llmq_devnet_params.is_some() { + return Err("--llmq-devnet-params is only valid with --network=devnet".into()); + } + } + // Add custom peers if specified if !args.peer.is_empty() { config.peers.clear(); diff --git a/dash/src/network/constants.rs b/dash/src/network/constants.rs index 324789cf6..6fc4268bc 100644 --- a/dash/src/network/constants.rs +++ b/dash/src/network/constants.rs @@ -52,9 +52,6 @@ pub const PROTOCOL_VERSION: u32 = 70237; pub trait NetworkExt { /// Returns the known genesis block hash for `network`, if one is hardcoded. - /// - /// `Network::Devnet` returns `None` because devnets use dynamically-generated - /// genesis blocks. fn known_genesis_block_hash(&self) -> Option; } @@ -75,7 +72,13 @@ impl NetworkExt for Network { block_hash.reverse(); Some(BlockHash::from_byte_array(block_hash.try_into().expect("expected 32 bytes"))) } - Network::Devnet => None, + Network::Devnet => { + let mut block_hash = + hex::decode("000008ca1832a4baf228eb1553c03d3a2c8e02399550dd6ea8d65cec3ef23d2e") + .expect("expected valid hex"); + block_hash.reverse(); + Some(BlockHash::from_byte_array(block_hash.try_into().expect("expected 32 bytes"))) + } Network::Regtest => { let mut block_hash = hex::decode("000008ca1832a4baf228eb1553c03d3a2c8e02399550dd6ea8d65cec3ef23d2e") diff --git a/dash/src/sml/llmq_type/mod.rs b/dash/src/sml/llmq_type/mod.rs index 9fb8b3334..573804e14 100644 --- a/dash/src/sml/llmq_type/mod.rs +++ b/dash/src/sml/llmq_type/mod.rs @@ -3,6 +3,7 @@ pub mod rotation; use std::fmt::{Display, Formatter}; use std::io; +use std::sync::OnceLock; #[cfg(feature = "bincode")] use bincode::{Decode, Encode}; @@ -207,6 +208,49 @@ pub const LLMQ_DEVNET: LLMQParams = LLMQParams { recovery_members: 6, }; +/// Runtime override values for `LLMQ_DEVNET`, matching Dash Core's +/// `-llmqdevnetparams=:`. +#[repr(C)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub struct LlmqDevnetParams { + /// Quorum size (total members). + pub size: u32, + /// Signing threshold (also used as min_size and bad_votes_threshold). + pub threshold: u32, +} + +/// Runtime override for `LLMQ_DEVNET` params, matching Dash Core's `-llmqdevnetparams`. +static LLMQ_DEVNET_OVERRIDE: OnceLock = OnceLock::new(); + +/// Override the `LLMQ_DEVNET` quorum size and threshold (matches Dash Core's +/// `-llmqdevnetparams=:`). Idempotent for identical values, +/// returns an error if a conflicting override was already set. +pub fn set_llmq_devnet_params(params: LlmqDevnetParams) -> Result<(), &'static str> { + match LLMQ_DEVNET_OVERRIDE.get() { + Some(&existing) if existing == params => Ok(()), + Some(_) => Err("LLMQ_DEVNET params already set to a different value"), + None => LLMQ_DEVNET_OVERRIDE + .set(params) + .map_err(|_| "LLMQ_DEVNET params already set to a different value"), + } +} + +/// Get the effective `LLMQ_DEVNET` params, applying any runtime override. +pub fn llmq_devnet_params() -> LLMQParams { + let mut params = LLMQ_DEVNET; + if let Some(&LlmqDevnetParams { + size, + threshold, + }) = LLMQ_DEVNET_OVERRIDE.get() + { + params.size = size; + params.min_size = threshold; + params.threshold = threshold; + params.dkg_params.bad_votes_threshold = threshold; + } + params +} + pub const LLMQ_50_60: LLMQParams = LLMQParams { quorum_type: LLMQType::Llmqtype50_60, name: "llmq_50_60", @@ -358,14 +402,14 @@ impl LLMQType { LLMQType::Llmqtype60_75 => LLMQ_60_75, LLMQType::Llmqtype25_67 => LLMQ_25_67, LLMQType::LlmqtypeTest => LLMQ_TEST, - LLMQType::LlmqtypeDevnet => LLMQ_DEVNET, + LLMQType::LlmqtypeDevnet => llmq_devnet_params(), LLMQType::LlmqtypeTestV17 => LLMQ_V017, LLMQType::LlmqtypeTestDIP0024 => LLMQ_TEST_DIP00024, LLMQType::LlmqtypeTestInstantSend => LLMQ_TEST_INSTANT_SEND, LLMQType::LlmqtypeDevnetDIP0024 => LLMQ_0024, LLMQType::LlmqtypeTestnetPlatform => LLMQ_TEST_PLATFORM, LLMQType::LlmqtypeDevnetPlatform => LLMQ_DEV_PLATFORM, - LLMQType::LlmqtypeUnknown => LLMQ_DEVNET, + LLMQType::LlmqtypeUnknown => llmq_devnet_params(), } } pub fn size(&self) -> u32 { @@ -661,4 +705,40 @@ mod tests { assert_eq!(params.threshold, 67); assert_eq!(params.signing_active_quorum_count, 24); } + + #[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, + }) + .expect("initial override should succeed"); + + let params = llmq_devnet_params(); + assert_eq!(params.size, 8); + assert_eq!(params.min_size, 5); + assert_eq!(params.threshold, 5); + assert_eq!(params.dkg_params.bad_votes_threshold, 5); + + set_llmq_devnet_params(LlmqDevnetParams { + size: 8, + threshold: 5, + }) + .expect("re-setting identical values should be idempotent"); + assert!( + set_llmq_devnet_params(LlmqDevnetParams { + size: 12, + threshold: 6, + }) + .is_err(), + "conflicting override must error" + ); + + let params_after = llmq_devnet_params(); + assert_eq!(params_after.size, 8); + assert_eq!(params_after.threshold, 5); + } } diff --git a/dash/src/sml/llmq_type/network.rs b/dash/src/sml/llmq_type/network.rs index a0fc6c9ab..65a6b7a00 100644 --- a/dash/src/sml/llmq_type/network.rs +++ b/dash/src/sml/llmq_type/network.rs @@ -45,7 +45,7 @@ impl NetworkLLMQExt for Network { match self { Network::Mainnet => LLMQType::Llmqtype100_67, Network::Testnet => LLMQType::Llmqtype25_67, - Network::Devnet => LLMQType::LlmqtypeDevnet, + Network::Devnet => LLMQType::LlmqtypeDevnetPlatform, Network::Regtest => LLMQType::LlmqtypeTestnetPlatform, } }