From 7607dc82f45e676a9132f52ed5d8a90197d07dd4 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 7 Aug 2026 16:12:39 -0500 Subject: [PATCH] Support custom Signet networks Accept BIP325 challenges and target spacing from the CLI or config. Derive P2P magic from each challenge and avoid default Signet seeds so custom networks cannot mix peer sets. --- OPERATOR.md | 25 +++++++ README.md | 3 + crates/rbitcoin-consensus/src/lib.rs | 2 +- crates/rbitcoin-consensus/src/params.rs | 34 ++++++++- crates/rbitcoin-consensus/src/signet.rs | 26 ++++++- crates/rbitcoin-net/src/lib.rs | 2 +- crates/rbitcoin-net/src/service.rs | 24 +++++- crates/rbitcoin-node/src/cli.rs | 67 +++++++++++++++++ crates/rbitcoin-node/src/config.rs | 98 ++++++++++++++++++++++++- crates/rbitcoin-node/src/run.rs | 19 ++++- 10 files changed, 286 insertions(+), 14 deletions(-) diff --git a/OPERATOR.md b/OPERATOR.md index 3f04a87..cf4eaa0 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -41,6 +41,8 @@ Routine knobs are **CLI / conf**, not required env vars. Clean smoke: |------|----------------|---------| | `--datadir PATH` | same | `./datadir` | | `--network NET` | `--chain` | `mainnet` | +| `--signetchallenge HEX` | `--signet-challenge` | default global Signet challenge | +| `--signetblocktime SECONDS` | `--signet-block-time` | 600; requires a custom challenge | | `--listen ADDR` | | bind later default port | | `--connect ADDR` | (repeatable) | seeds | | `--milestone HEIGHT` | `--assumevalid-height` | network default (mainnet 840000) | @@ -304,6 +306,29 @@ mkdir -p ./datadir-signet --log-level info ``` +### Custom Signet + +A custom Signet derives its P2P message magic from the challenge. Default +Signet seeds are not used, so provide at least one peer with `--connect`. +Use a dedicated datadir for each challenge. + +```bash +mkdir -p ./datadir-custom-signet +./target/release/rbitcoin-node \ + --datadir ./datadir-custom-signet \ + --network signet \ + --signetchallenge 51 \ + --signetblocktime 60 \ + --connect 192.0.2.1:38333 \ + --listen 0.0.0.0:38333 \ + --milestone 0 \ + --log-level info +``` + +The equivalent conf-file keys are `signetchallenge` and `signetblocktime`. +Replace the illustrative `OP_TRUE` challenge and documentation-only peer with +the parameters supplied by the custom Signet operator. + ### Resume / clean stop Same `--datadir` resumes tip from the relational archive. diff --git a/README.md b/README.md index d99a509..1f24aab 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ install -m 755 result/bin/rbitcoin-node result/bin/rbitcoin-cli target/release/ --listen 127.0.0.1:38333 --milestone 200000 --max-run-secs 120 ``` +Custom Signets are supported with `--signetchallenge` and +`--signetblocktime`; see the [custom Signet example](./OPERATOR.md#custom-signet). + ## Build ### Portable static release (recommended) diff --git a/crates/rbitcoin-consensus/src/lib.rs b/crates/rbitcoin-consensus/src/lib.rs index 9243d41..b1d60c9 100644 --- a/crates/rbitcoin-consensus/src/lib.rs +++ b/crates/rbitcoin-consensus/src/lib.rs @@ -104,7 +104,7 @@ pub use header::{expected_next_bits, median_time_past, validate_header}; pub use milestone::Milestone; pub use params::{default_milestone_height, genesis_block, ChainParams, Checkpoint}; pub use policy::{check_tx_standard, is_push_only, is_standard_script_pubkey, PolicyResult}; -pub use signet::{default_signet_challenge, validate_signet_block_solution}; +pub use signet::{default_signet_challenge, signet_magic, validate_signet_block_solution}; pub fn crate_name() -> &'static str { "rbitcoin-consensus" diff --git a/crates/rbitcoin-consensus/src/params.rs b/crates/rbitcoin-consensus/src/params.rs index bbca9ee..02bb622 100644 --- a/crates/rbitcoin-consensus/src/params.rs +++ b/crates/rbitcoin-consensus/src/params.rs @@ -71,15 +71,29 @@ impl ChainParams { } pub fn signet() -> Self { + Self::custom_signet(crate::signet::default_signet_challenge(), 10 * 60) + .expect("default signet block time is nonzero") + } + + /// Build custom BIP325 signet parameters. + /// + /// `block_time` changes PoW target spacing while retaining Signet's two-week + /// target timespan, matching Bitcoin Core's `signetblocktime` behavior. + pub fn custom_signet(challenge: ScriptBuf, block_time: u64) -> Result { + if block_time == 0 { + return Err("signet block time must be greater than zero"); + } let genesis = constants::genesis_block(Network::Signet); - Self { + let mut btc = BtcParams::new(Network::Signet); + btc.pow_target_spacing = block_time; + Ok(Self { network: Network::Signet, genesis_hash: genesis.block_hash(), pow_limit: Target::MAX_ATTAINABLE_SIGNET, checkpoints: vec![], - btc: BtcParams::new(Network::Signet), - signet_challenge: Some(crate::signet::default_signet_challenge()), - } + btc, + signet_challenge: Some(challenge), + }) } pub fn checkpoint_at(&self, height: Height) -> Option { @@ -313,6 +327,18 @@ mod tests { assert!(p.signet_challenge.is_some()); } + #[test] + fn custom_signet_uses_challenge_and_block_time() { + let challenge = ScriptBuf::from_bytes(vec![0x51]); + let p = ChainParams::custom_signet(challenge.clone(), 60).unwrap(); + + assert_eq!(p.signet_challenge.as_ref(), Some(&challenge)); + assert_eq!(p.btc.pow_target_spacing, 60); + assert_eq!(p.btc.pow_target_timespan, 14 * 24 * 60 * 60); + assert_eq!(p.difficulty_adjustment_interval(), 20_160); + assert!(ChainParams::custom_signet(challenge, 0).is_err()); + } + /// Mainnet buried heights (Core + Inquisition). #[test] fn mainnet_buried_deployments_match_core() { diff --git a/crates/rbitcoin-consensus/src/signet.rs b/crates/rbitcoin-consensus/src/signet.rs index 441fc8c..9a006ac 100644 --- a/crates/rbitcoin-consensus/src/signet.rs +++ b/crates/rbitcoin-consensus/src/signet.rs @@ -6,7 +6,7 @@ //! expensive for the IBD prep path). use bitcoin::absolute::LockTime; -use bitcoin::consensus::Encodable; +use bitcoin::consensus::{serialize, Encodable}; use bitcoin::hashes::{sha256d, Hash}; use bitcoin::script::{Script, ScriptBuf}; use bitcoin::{Amount, Block, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; @@ -26,6 +26,17 @@ pub fn default_signet_challenge() -> ScriptBuf { )) } +/// Derive the four P2P message-start bytes for a BIP325 challenge. +/// +/// Bitcoin Core hashes the consensus-serialized challenge byte vector, including +/// its CompactSize length prefix, and uses the first four digest bytes. +pub fn signet_magic(challenge: &Script) -> [u8; 4] { + let encoded = serialize(&challenge.as_bytes().to_vec()); + sha256d::Hash::hash(&encoded).to_byte_array()[..4] + .try_into() + .expect("four-byte digest prefix") +} + fn hex_decode(s: &str) -> Vec { (0..s.len()) .step_by(2) @@ -436,6 +447,19 @@ mod tests { .expect("BIP325 solution for real signet height 1"); } + #[test] + fn custom_challenge_derives_expected_wire_magic() { + let challenge = ScriptBuf::from_bytes(vec![0x51]); + assert_eq!( + signet_magic(challenge.as_script()), + [0x54, 0xd2, 0x6f, 0xbd] + ); + assert_eq!( + signet_magic(default_signet_challenge().as_script()), + [0x0a, 0x03, 0xcf, 0x40] + ); + } + #[test] fn signet_block_1_rejects_mutated_solution() { let raw = include_bytes!("../tests/fixtures/signet_block_1.bin"); diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index 668f1e6..cfa9abd 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -30,7 +30,7 @@ pub use seeds::{ default_port, dns_seeds, fixed_seed_hosts, resolve_all_seeds, resolve_dns_seeds, resolve_fixed_seeds, AddrMan, PeerEntry, PeerFlags, }; -pub use service::{magic_for, NetConfig, P2PHandle, P2PNode}; +pub use service::{magic_for, magic_for_params, NetConfig, P2PHandle, P2PNode}; pub use tx_relay::{ decode_len_prefixed_package, ElectrumMempoolItem, MempoolHub, QueryUtxoProvider, }; diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index 3531f00..e8c9074 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -9,7 +9,7 @@ use crate::peer_dos::{inbound_semaphore, max_inbound_from_env}; use bitcoin::p2p::Magic; use bitcoin::Block; use bitcoin::BlockHash; -use rbitcoin_consensus::{ChainParams, Milestone}; +use rbitcoin_consensus::{signet_magic, ChainParams, Milestone}; use rbitcoin_primitives::Network as RNetwork; use rbitcoin_query::Query; use std::net::SocketAddr; @@ -65,7 +65,7 @@ impl P2PNode { params: ChainParams, milestone: Milestone, ) -> Result { - let magic = Magic::from(params.network); + let magic = magic_for_params(¶ms); let hub = Arc::new(ChainHub::new(query, params, milestone)); hub.ensure_genesis()?; let cache = hub.cache.clone(); @@ -279,6 +279,14 @@ pub fn magic_for(network: RNetwork) -> Magic { }) } +/// Resolve P2P message magic, including BIP325 custom-Signet derivation. +pub fn magic_for_params(params: &ChainParams) -> Magic { + match params.signet_challenge.as_ref() { + Some(challenge) => Magic::from_bytes(signet_magic(challenge.as_script())), + None => Magic::from(params.network), + } +} + #[cfg(test)] mod tests { use super::*; @@ -303,4 +311,16 @@ mod tests { assert!(cfg.listen.is_none()); assert_eq!(cfg.user_agent, "/rbitcoin:0.1.0/"); } + + #[test] + fn custom_signet_uses_challenge_derived_magic() { + use bitcoin::ScriptBuf; + + let challenge = ScriptBuf::from_bytes(vec![0x51]); + let params = ChainParams::custom_signet(challenge, 60).unwrap(); + assert_eq!( + magic_for_params(¶ms), + Magic::from_bytes([0x54, 0xd2, 0x6f, 0xbd]) + ); + } } diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 136aeac..3539509 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -21,6 +21,8 @@ where let mut datadir_set = false; let mut network = Network::Mainnet; let mut network_set = false; + let mut signet_challenge = None; + let mut signet_block_time = None; let mut smoke = false; let mut listen: Option = None; let mut electrum_listen: Option = None; @@ -55,6 +57,7 @@ where [--mempool-size-mb|--maxmempool N] [--archive-queue-mb N] \\\n\ [--max-run-secs N] [--log-level LEVEL] [--no-seeds] [--smoke] [--inhibit-suspend]\n\n\ Networks: mainnet|testnet|signet|regtest\n\ +Custom Signet: --signetchallenge HEX [--signetblocktime SECONDS].\n\ Log level: error|warn|info|debug|trace|off (CLI > conf log_level > RBITCOIN_LOG / RUST_LOG).\n\ Milestone / assumevalid-height: skip script/sig checks at/below HEIGHT.\n\ Defaults: mainnet 840000, signet 2000000, testnet 2500000, regtest 0. Use 0 for full scripts.\n\ @@ -122,6 +125,40 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", } i += 1; } + "--signetchallenge" | "--signet-challenge" => { + i += 1; + if i >= args.len() { + eprintln!("error: --signetchallenge requires hexadecimal script bytes"); + return ExitCode::from(2); + } + match crate::config::parse_signet_challenge(&args[i].to_string_lossy()) { + Ok(challenge) => signet_challenge = Some(challenge), + Err(e) => { + eprintln!("error: bad --signetchallenge: {e}"); + return ExitCode::from(2); + } + } + i += 1; + } + "--signetblocktime" | "--signet-block-time" => { + i += 1; + if i >= args.len() { + eprintln!("error: --signetblocktime requires seconds"); + return ExitCode::from(2); + } + match args[i].to_string_lossy().parse::() { + Ok(n) if n > 0 => signet_block_time = Some(n), + Ok(_) => { + eprintln!("error: --signetblocktime must be greater than zero"); + return ExitCode::from(2); + } + Err(e) => { + eprintln!("error: bad --signetblocktime: {e}"); + return ExitCode::from(2); + } + } + i += 1; + } "--listen" => { i += 1; if i >= args.len() { @@ -358,6 +395,12 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", if network_set { config.network = network; } + if let Some(challenge) = signet_challenge { + config.signet_challenge = Some(challenge); + } + if signet_block_time.is_some() { + config.signet_block_time = signet_block_time; + } if let Some(a) = listen { config.p2p_listen = Some(a); } @@ -609,6 +652,30 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn custom_signet_cli_smoke() { + let dir = tmp_datadir(); + let code = cli_main([ + "rbitcoin-node", + "--smoke", + "--network", + "signet", + "--datadir", + dir.to_str().unwrap(), + "--signetchallenge", + "51", + "--signetblocktime", + "60", + "--no-seeds", + "--log-level", + "error", + "--milestone", + "0", + ]); + assert_exit(code, ExitCode::SUCCESS); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn help_lists_coreish_flags_not_only_env() { let _g = OPERATOR_ENV_TEST_LOCK diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 3c0eb22..a56b91d 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -1,5 +1,7 @@ use crate::error::NodeError; -use rbitcoin_consensus::Milestone; +use bitcoin::hex::FromHex; +use bitcoin::ScriptBuf; +use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Network; use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -23,6 +25,10 @@ pub const DEFAULT_MAX_INBOUND: u32 = 125; pub struct NodeConfig { pub datadir: PathBuf, pub network: Network, + /// Custom BIP325 challenge. `None` selects the default global Signet. + pub signet_challenge: Option, + /// Custom Signet PoW target spacing in seconds. + pub signet_block_time: Option, pub archive_durability: bool, pub wire_depth_blocks: u32, /// Bind address for P2P listen (`None` = do not listen / default bind later). @@ -66,6 +72,8 @@ impl Default for NodeConfig { Self { datadir: PathBuf::from("./datadir"), network: Network::Mainnet, + signet_challenge: None, + signet_block_time: None, archive_durability: true, wire_depth_blocks: 100, p2p_listen: None, @@ -123,6 +131,17 @@ impl NodeConfig { } } + /// Compose immutable consensus parameters from operator configuration. + pub fn chain_params(&self) -> Result { + match self.signet_challenge.clone() { + Some(challenge) => { + ChainParams::custom_signet(challenge, self.signet_block_time.unwrap_or(10 * 60)) + .map_err(|e| NodeError::Config(e.into())) + } + None => Ok(ChainParams::for_network(self.network)), + } + } + pub fn validate(&self) -> Result<(), NodeError> { if self.datadir.as_os_str().is_empty() { return Err(NodeError::Config("datadir must not be empty".into())); @@ -133,6 +152,23 @@ impl NodeConfig { if self.max_inbound == 0 { return Err(NodeError::Config("max_inbound must be >= 1".into())); } + if (self.signet_challenge.is_some() || self.signet_block_time.is_some()) + && self.network != Network::Signet + { + return Err(NodeError::Config( + "signetchallenge and signetblocktime require network=signet".into(), + )); + } + if self.signet_block_time.is_some() && self.signet_challenge.is_none() { + return Err(NodeError::Config( + "signetblocktime requires signetchallenge".into(), + )); + } + if self.signet_block_time == Some(0) { + return Err(NodeError::Config( + "signetblocktime must be greater than zero".into(), + )); + } let _ = (self.wire_depth_blocks, self.archive_durability); Ok(()) } @@ -182,7 +218,8 @@ impl NodeConfig { /// Supported keys: `datadir`, `network` / `chain`, `listen`, `connect` (repeatable), /// `milestone` / `assumevalid_height`, `maxoutbound` / `max_outbound`, /// `maxinbound` / `max_inbound` / `maxconnections`, `mempool_size_mb` / `maxmempool`, - /// `archive_queue_mb`, `log_level`, `electrum_listen`, `noseeds` / `no_seeds`. + /// `archive_queue_mb`, `log_level`, `electrum_listen`, `noseeds` / `no_seeds`, + /// `signetchallenge`, and `signetblocktime`. pub fn merge_conf_file(&mut self, path: &Path) -> Result<(), NodeError> { let text = std::fs::read_to_string(path).map_err(|source| { NodeError::Config(format!("read conf {}: {source}", path.display())) @@ -223,6 +260,18 @@ impl NodeConfig { self.network = Network::parse(val) .map_err(|e| NodeError::Config(format!("conf network: {e}")))?; } + "signetchallenge" | "signet_challenge" => { + self.signet_challenge = + Some(parse_signet_challenge(val).map_err(|e| { + NodeError::Config(format!("conf signetchallenge: {e}")) + })?); + } + "signetblocktime" | "signet_block_time" => { + self.signet_block_time = + Some(val.parse().map_err(|e| { + NodeError::Config(format!("conf signetblocktime: {e}")) + })?); + } "listen" => { self.p2p_listen = Some( val.parse() @@ -299,6 +348,12 @@ impl NodeConfig { } } +pub(crate) fn parse_signet_challenge(value: &str) -> Result { + Vec::::from_hex(value) + .map(ScriptBuf::from_bytes) + .map_err(|e| format!("must be hexadecimal: {e}")) +} + fn is_conf_true(val: &str) -> bool { matches!( val.to_ascii_lowercase().as_str(), @@ -446,6 +501,45 @@ mod tests { ); } + #[test] + fn custom_signet_conf_builds_params() { + let dir = tmp(); + std::fs::create_dir_all(&dir).unwrap(); + let conf = dir.join("custom-signet.conf"); + std::fs::write( + &conf, + "network=signet\n\ + signetchallenge=51\n\ + signetblocktime=60\n", + ) + .unwrap(); + + let mut cfg = NodeConfig::default(); + cfg.merge_conf_file(&conf).unwrap(); + cfg.validate().unwrap(); + let params = cfg.chain_params().unwrap(); + assert_eq!(params.btc.pow_target_spacing, 60); + assert_eq!(params.signet_challenge.unwrap().as_bytes(), &[0x51]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn custom_signet_options_require_signet_and_challenge() { + let challenge = bitcoin::ScriptBuf::from_bytes(vec![0x51]); + let mainnet = NodeConfig { + signet_challenge: Some(challenge), + ..NodeConfig::default() + }; + assert!(mainnet.validate().is_err()); + + let missing_challenge = NodeConfig { + network: Network::Signet, + signet_block_time: Some(30), + ..NodeConfig::default() + }; + assert!(missing_challenge.validate().is_err()); + } + #[test] fn ensure_datadir_rejects_file_path_after_parent_exists() { let dir = tmp(); diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index e4753ec..dd45b3b 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -1,7 +1,6 @@ use crate::config::NodeConfig; use crate::error::NodeError; use bitcoin::consensus::Encodable; -use rbitcoin_consensus::ChainParams; use rbitcoin_electrum::{run_electrum, ElectrumConfig, TipNotify}; use rbitcoin_log::{info, warn}; use rbitcoin_net::{default_port, AddrMan, IbdConfig, MempoolHub, P2PNode, TipEvent}; @@ -149,7 +148,7 @@ pub fn run_node(config: NodeConfig) -> Result { /// and aborts peer tasks. pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { let handle = run_node(config.clone())?; - let params = ChainParams::for_network(config.network); + let params = config.chain_params()?; let milestone = config.milestone(); if milestone.height > 0 { info!( @@ -249,7 +248,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { for c in &config.connect { addrman.add(*c); } - if config.use_seeds && config.connect.is_empty() { + if should_resolve_default_seeds(&config) { info!( "ibd: resolving DNS/fixed seeds for {}…", config.network.as_str() @@ -261,6 +260,8 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { addrman.len().saturating_sub(n_before), addrman.len() ); + } else if config.signet_challenge.is_some() && config.connect.is_empty() && addrman.is_empty() { + warn!("custom signet has no peers; use --connect ADDR or reuse a datadir with known peers"); } // Shared with IBD so learned addrs/flags flush back on IBD exit. let shared_peers = std::sync::Arc::new(std::sync::Mutex::new(addrman.clone())); @@ -695,6 +696,10 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { Ok(()) } +fn should_resolve_default_seeds(config: &NodeConfig) -> bool { + config.use_seeds && config.connect.is_empty() && config.signet_challenge.is_none() +} + /// Enter steady-state tip mode after true catch-up. /// /// **Preconditions (enforced by IBD, not repaired here):** Direct catch-up already @@ -784,6 +789,14 @@ mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn custom_signet_does_not_use_default_signet_seeds() { + let mut cfg = NodeConfig::default().with_network(rbitcoin_primitives::Network::Signet); + assert!(should_resolve_default_seeds(&cfg)); + cfg.signet_challenge = Some(bitcoin::ScriptBuf::from_bytes(vec![0x51])); + assert!(!should_resolve_default_seeds(&cfg)); + } + #[test] fn enter_tip_mode_reenables_indexes() { use rbitcoin_query::IndexMode;