Skip to content
26 changes: 26 additions & 0 deletions dash-spv/src/client/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<u32>,

/// Override for `LLMQ_DEVNET` quorum size and threshold, applied at startup.
/// Mirrors Dash Core's `-llmqdevnetparams=<size>:<threshold>`. Only meaningful on devnet.
pub llmq_devnet_params: Option<LlmqDevnetParams>,
}

impl Default for ClientConfig {
Expand All @@ -90,6 +95,7 @@ impl Default for ClientConfig {
max_mempool_transactions: 1000,
fetch_mempool_transactions: true,
start_from_height: None,
llmq_devnet_params: None,
}
}
}
Expand Down Expand Up @@ -181,6 +187,13 @@ impl ClientConfig {
self
}

/// Override `LLMQ_DEVNET` quorum size and threshold for a devnet.
/// Mirrors Dash Core's `-llmqdevnetparams=<size>:<threshold>`.
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
Expand All @@ -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}",
Expand All @@ -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(())
}
Comment on lines +226 to +233
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

apply_global_overrides idempotency contract doesn’t match implementation.

The method comment says same-value calls are idempotent, but OnceLock::set fails on every second call regardless of value. This can break creating multiple clients in one process.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/client/config.rs` around lines 226 - 233, The comment and
implementation disagree because set_llmq_devnet_params currently uses a OnceLock
that errors on any second set; change apply_global_overrides (and/or
set_llmq_devnet_params) so repeated calls with the same llmq_devnet_params
(size, threshold) are treated as idempotent: when self.llmq_devnet_params is
Some((size, threshold)) first check the existing stored params via the
OnceLock/get API (or equivalent accessor) and if a value is already set compare
it to (size, threshold) and return Ok(()) when they match, but return Err(...)
only when they conflict; only call OnceLock::set (or perform initialization)
when no value is present. Ensure you reference apply_global_overrides,
llmq_devnet_params, set_llmq_devnet_params and the OnceLock-backed storage when
making the change so multiple clients can apply identical overrides without
error.

}
1 change: 1 addition & 0 deletions dash-spv/src/client/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ impl<W: WalletInterface, N: NetworkManager, S: StorageManager> DashSpvClient<W,

// Validate configuration
config.validate().map_err(SpvError::Config)?;
config.apply_global_overrides().map_err(SpvError::Config)?;

// Initialize genesis block or checkpoint before creating managers,
// so they can read the tip from storage during construction.
Expand Down
49 changes: 49 additions & 0 deletions dash-spv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::sync::Arc;

use clap::{Parser, ValueEnum};
use dash_spv::{ClientConfig, DashSpvClient, LevelFilter, MempoolStrategy, Network};
use dashcore::sml::llmq_type::LlmqDevnetParams;
use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
use key_wallet_manager::WalletManager;

Expand All @@ -14,6 +15,7 @@ use key_wallet_manager::WalletManager;
enum NetworkArg {
Mainnet,
Testnet,
Devnet,
Regtest,
}

Expand All @@ -22,6 +24,7 @@ impl From<NetworkArg> for Network {
match arg {
NetworkArg::Mainnet => Network::Mainnet,
NetworkArg::Testnet => Network::Testnet,
NetworkArg::Devnet => Network::Devnet,
NetworkArg::Regtest => Network::Regtest,
}
}
Expand Down Expand Up @@ -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<String>,

/// Override `LLMQ_DEVNET` size and threshold (matches Dash Core's `-llmqdevnetparams=<size>:<threshold>`).
#[arg(long, value_name = "SIZE:THRESHOLD")]
llmq_devnet_params: Option<String>,
}

#[tokio::main]
Expand Down Expand Up @@ -212,6 +223,44 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.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);
Comment thread
xdustinface marked this conversation as resolved.

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();
Expand Down
11 changes: 7 additions & 4 deletions dash/src/network/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockHash>;
}

Expand All @@ -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")))
}
Comment thread
xdustinface marked this conversation as resolved.
Network::Regtest => {
let mut block_hash =
hex::decode("000008ca1832a4baf228eb1553c03d3a2c8e02399550dd6ea8d65cec3ef23d2e")
Expand Down
84 changes: 82 additions & 2 deletions dash/src/sml/llmq_type/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -207,6 +208,49 @@ pub const LLMQ_DEVNET: LLMQParams = LLMQParams {
recovery_members: 6,
};

/// Runtime override values for `LLMQ_DEVNET`, matching Dash Core's
/// `-llmqdevnetparams=<size>:<threshold>`.
#[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<LlmqDevnetParams> = OnceLock::new();

/// Override the `LLMQ_DEVNET` quorum size and threshold (matches Dash Core's
/// `-llmqdevnetparams=<size>:<threshold>`). 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"),
Comment thread
xdustinface marked this conversation as resolved.
}
}

/// 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
}
Comment on lines +222 to +252
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add focused tests for the new process-wide devnet override path.

Please add unit tests for: valid override application, invalid value rejection, and second-set behavior.

As per coding guidelines, "Write unit tests for new functionality".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash/src/sml/llmq_type/mod.rs` around lines 211 - 230, Add three tests that
exercise the process-wide override: write test_valid_override to call
set_llmq_devnet_params(size,threshold) with a valid pair and then assert
llmq_devnet_params() reflects size, min_size, threshold and
dkg_params.bad_votes_threshold; write test_invalid_override_rejected to call
set_llmq_devnet_params with invalid values (e.g., threshold > size or zero) and
assert it returns Err; write test_second_set_fails to call
set_llmq_devnet_params successfully then call it again and assert the second
call returns Err; because LLMQ_DEVNET_OVERRIDE is process-global, run each test
in its own process (spawn the test binary with --exact <test_name> or use
Command to re-exec the current test binary) so the OnceLock is fresh for each
case; reference functions/setter llmq_devnet_params, set_llmq_devnet_params and
static LLMQ_DEVNET_OVERRIDE and type LLMQParams when locating code to assert
fields.


pub const LLMQ_50_60: LLMQParams = LLMQParams {
quorum_type: LLMQType::Llmqtype50_60,
name: "llmq_50_60",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion dash/src/sml/llmq_type/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
Loading